diff --git a/hypn0/hypn0_site/services/halftone.py b/hypn0/hypn0_site/services/halftone.py index 048c01b..c3e5746 100644 --- a/hypn0/hypn0_site/services/halftone.py +++ b/hypn0/hypn0_site/services/halftone.py @@ -188,22 +188,10 @@ def generate_halftone_svg( 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. Обход пикселей и группировка + # 2. Обход пикселей и группировка elements_by_class = defaultdict(list) unique_radii = set() + is_animated = blink > 0 for y in range(grid_height): for x in range(grid_width): @@ -221,13 +209,13 @@ def generate_halftone_svg( 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) + anim_index = rng.randint(0, animation_variants - 1) if is_animated else 0 + encoded_class = encode_to_base36(anim_index) - elements_by_class[encoded_class].append((radius, cx, cy)) + elements_by_class[encoded_class].append((radius, cx, cy, anim_index)) unique_radii.add(radius) - # 4. Формирование + # 3. Формирование defs_list = [""] for radius in sorted(unique_radii): shape_id = f"s{encode_to_base36(radius)}" @@ -235,16 +223,31 @@ def generate_halftone_svg( defs_list.append("") defs_html = "".join(defs_list) - # 5. Формирование групп + # 4. Формирование групп и CSS классов задержек + duration = max(0.4, 2.5 - (blink * 0.19)) if is_animated else 1.0 groups = [] - for anim_class in sorted(elements_by_class.keys()): - group_elements = elements_by_class[anim_class] + 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] + delay = (anim_index / animation_variants) * duration * 1.5 if is_animated else 0.0 + + # Генерируем правило задержки в 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}}") + uses = "".join( f'' - for radius, cx, cy in group_elements + for radius, cx, cy, _ in group_elements ) if uses: - groups.append(f'{uses}') + groups.append(f'{uses}') animation_css = "".join(animation_classes) groups_html = "".join(groups) @@ -267,15 +270,15 @@ def generate_halftone_svg( # По умолчанию running (для живого превью и детального просмотра). # В галерее значение переопределяется на paused для 0% нагрузки на процессор. anim_rule = ( - f"animation:noise {duration:.2f}s ease-in-out infinite alternate;" - f"animation-delay:var(--d);" + 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);" ) 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"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"}}" ) else: @@ -285,9 +288,9 @@ def generate_halftone_svg( svg_css = ( f"" ) @@ -326,7 +329,7 @@ def prepare_gallery_svg(svg_content: str) -> str: 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}" + "circle,rect,polygon,path{animation-play-state:var(--hypn0-play,paused)!important}" ) if "" in svg_content: @@ -349,7 +352,9 @@ 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}.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}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}", ] cleaned = svg_content for gcss in gallery_css_variants: @@ -358,7 +363,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\s*\{animation-play-state:\s*var\(--hypn0-play,\s*paused\)!important\}", + r"(\.shape,)?circle,rect,polygon,path\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 68fb4e3..91c0df1 100644 --- a/hypn0/hypn0_site/tests.py +++ b/hypn0/hypn0_site/tests.py @@ -114,6 +114,7 @@ class HalftoneServiceTests(BaseMediaTestCase): self.assertIn("rotate(12deg)", svg) self.assertIn("scale(0.92)", svg) self.assertIn("#ff5500", svg) + self.assertIn(".aH{--d:0s}", svg) def test_generate_from_bytes(self): buf = io.BytesIO() @@ -371,6 +372,18 @@ class SvgAnalysisAndActiveSvgTests(BaseMediaTestCase): active = prepare_active_svg(gallery_svg) self.assertNotIn("animation-play-state:paused!important", active) + def test_prepare_active_svg_modern_format(self): + img = Image.new("RGB", (20, 20), color="black") + raw_svg = generate_halftone_svg(img, cols=15, max_radius=6, blink=5) + gallery_svg = prepare_gallery_svg(raw_svg) + self.assertIn("svg{--hypn0-play:paused}", gallery_svg) + self.assertIn("animation-play-state:var(--hypn0-play,paused)!important", gallery_svg) + + active = prepare_active_svg(gallery_svg) + self.assertNotIn("svg{--hypn0-play:paused}", active) + self.assertNotIn("animation-play-state:var(--hypn0-play,paused)!important", active) + self.assertIn("animation-play-state:var(--hypn0-play,running)", active) + def test_prepare_active_svg_preserves_color_and_animation(self): gallery_svg = ( '' diff --git a/hypn0/templates/gallery/detail.html b/hypn0/templates/gallery/detail.html index b98cbfc..9fb5bc6 100644 --- a/hypn0/templates/gallery/detail.html +++ b/hypn0/templates/gallery/detail.html @@ -106,6 +106,7 @@ max-height: 100%; object-fit: contain; display: block; + --hypn0-play: running !important; } {{ active_svg|safe }}