add: галерея (4) первый этаж (плеск бессознательного) улучшен 3

This commit is contained in:
2026-09-02 01:04:06 +03:00
parent b7b01b6b54
commit 98e73a0c53
7 changed files with 233 additions and 19 deletions
+13
View File
@@ -200,6 +200,19 @@ class TbHypn0Item(models.Model):
dark_bg = f"rgb({max(9, int(9 + r * 0.07))}, {max(9, int(9 + g * 0.07))}, {max(13, int(13 + b * 0.08))})"
return f"--card-bg-light: {light_bg}; --card-bg-dark: {dark_bg};"
@property
def card_svg(self) -> str:
"""
Возвращает SVG-код картины для изолированного рендеринга в карточке галереи.
"""
if not self.file_svg:
return ""
try:
with self.file_svg.open("r") as f:
return f.read()
except Exception:
return ""
def increment_views(self):
"""Безопасный инкремент просмотров"""
TbHypn0Item.objects.filter(id=self.id).update(i_views_count=F('i_views_count') + 1)
+30 -10
View File
@@ -229,7 +229,11 @@ def generate_halftone_svg(
fill_style = f"fill:{clean_color};stroke:{clean_color};opacity:{opacity:.2f};"
if is_animated:
anim_rule = f"animation:noise {duration:.2f}s ease-in-out infinite alternate;animation-delay:var(--d);"
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)}}"
@@ -266,17 +270,19 @@ def generate_halftone_svg(
def prepare_gallery_svg(svg_content: str) -> str:
"""
Модифицирует SVG для долговременного хранения в галерее:
добавляет CSS-правила, чтобы в изолированном контексте (<img>) анимация
находилась на паузе по умолчанию и запускалась только при наведении (:hover),
предотвращая перегрузку CPU/GPU браузера при выводе сетки карточек.
добавляет CSS Custom Property (--hypn0-play: paused), которое наследуется
внутрь элементов и теневых деревьев <use>. В изолированном контексте (Shadow DOM или <img>)
анимация заморожена по умолчанию (0% нагрузки на CPU), а при наведении (:hover или :host(:hover))
запускается.
"""
if not svg_content:
return svg_content
# Надежная глобальная пауза в контексте :root и пробуждение по ховеру
# Наследуемая переменная паузы и оверрайд стилей фигур
hover_css = (
"@media(hover:hover){:root{animation-play-state:paused!important}:root:hover *{animation-play-state:running!important}}"
"svg:not(:hover) *{animation-play-state: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}"
)
if "</style>" in svg_content:
@@ -293,10 +299,24 @@ def prepare_active_svg(svg_content: str) -> str:
if not svg_content:
return svg_content
# Удаляем любые внедренные правила пауз и медиа-запросов ховера
cleaned = re.sub(r"@media\s*\(\s*hover\s*:\s*hover\s*\)\s*\{[^}]*:[^}]*\}", "", 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}"
+39 -4
View File
@@ -238,9 +238,9 @@ class GalleryPreparationTests(TestCase):
def test_prepare_gallery_svg(self):
raw_svg = '<svg><style>.shape{animation:noise 1s}</style><g></g></svg>'
prepared = prepare_gallery_svg(raw_svg)
self.assertIn("@media(hover:hover)", prepared)
self.assertIn(":root{animation-play-state:paused!important}", prepared)
self.assertIn("svg:not(:hover)", prepared)
self.assertIn("svg{--hypn0-play:paused}", prepared)
self.assertIn(":host(:hover) svg,svg:hover{--hypn0-play:running!important}", prepared)
self.assertIn("animation-play-state:var(--hypn0-play,paused)!important", prepared)
class PublishViewTests(TestCase):
@@ -322,6 +322,23 @@ class SvgAnalysisAndActiveSvgTests(TestCase):
active = prepare_active_svg(gallery_svg)
self.assertNotIn("animation-play-state:paused!important", active)
def test_prepare_active_svg_preserves_color_and_animation(self):
gallery_svg = (
'<svg xmlns="http://www.w3.org/2000/svg">'
'<style>'
'svg{background:transparent}'
'.shape,circle,rect,polygon,path{fill:#e6b400;stroke:#e6b400;opacity:0.90;transform-origin:center;animation:noise 1.36s ease-in-out infinite alternate;animation-delay:var(--d);animation-play-state:var(--hypn0-play,running);transition:all .5s ease-out}'
'@keyframes noise{0%{opacity:0.63}100%{opacity:0.45}}'
'svg{--hypn0-play:paused}svg:hover{--hypn0-play:running}.shape,circle,rect,polygon,path{animation-play-state:var(--hypn0-play,paused)!important}'
'</style><g></g></svg>'
)
active = prepare_active_svg(gallery_svg)
self.assertIn("fill:#e6b400", active)
self.assertIn("stroke:#e6b400", active)
self.assertIn("animation:noise 1.36s", active)
self.assertNotIn("svg{--hypn0-play:paused}", active)
self.assertNotIn("animation-play-state:var(--hypn0-play,paused)!important", active)
def test_analyze_svg_structure(self):
svg = (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 600">'
@@ -560,7 +577,7 @@ class UnconsciousMatrixTests(TestCase):
hashes2 = [n["s_hash_id"] for n in m2]
self.assertEqual(hashes1, hashes2)
def test_gallery_detail_renders_matrix(self):
def test_gallery_detail_renders_matrix_and_card_bg_style(self):
item = self.items[0]
response = self.client.get(reverse("hypn0_site:gallery_detail", kwargs={"hash_id": item.s_hash_id}))
self.assertEqual(response.status_code, 200)
@@ -569,6 +586,9 @@ class UnconsciousMatrixTests(TestCase):
self.assertContains(response, f"#{item.s_hash_id}")
self.assertContains(response, "btn-nav-prev")
self.assertContains(response, "btn-nav-next")
self.assertContains(response, "--card-bg-light")
self.assertContains(response, "--card-bg-dark")
self.assertContains(response, "bg-[var(--card-bg-light)]")
class GalleryFreshFloorTests(TestCase):
@@ -729,3 +749,18 @@ class CardBgStyleTests(TestCase):
item_invalid = TbHypn0Item(j_metadata={"color": "invalid-hex"})
self.assertIn("--card-bg-light:", item_invalid.card_bg_style)
self.assertIn("--card-bg-dark:", item_invalid.card_bg_style)
def test_card_svg_property_and_shadow_dom_rendering(self):
item = TbHypn0Item(
s_title="Тестовый SVG",
file_svg=ContentFile(b'<svg id="test-svg"><circle/></svg>', name="test_card.svg"),
j_metadata={"color": "#10b981"},
is_public=True,
)
item.save(visitor_uuid_or_fp="123e4567-e89b-12d3-a456-426614174000")
self.assertIn('id="test-svg"', item.card_svg)
response = self.client.get(reverse("hypn0_site:index"))
self.assertContains(response, 'template shadowrootmode="open"')
self.assertContains(response, "hypn0-card-svg")
self.assertContains(response, "--hypn0-play: running !important")