From 9d3584e96e385da321f06bbd3bd2cae19b0489ff Mon Sep 17 00:00:00 2001 From: erjemin Date: Thu, 27 Aug 2026 00:06:15 +0300 Subject: [PATCH] =?UTF-8?q?mod:=20=D1=8F=D0=B4=D1=80=D0=BE=20(04)=20SVG-?= =?UTF-8?q?=D0=B3=D0=B5=D0=BD=D0=B5=D1=80=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=BF=D0=B8=D1=81=D1=8B=D0=B2=D0=B0=D0=B5=D1=82=D1=81?= =?UTF-8?q?=D1=8F=20=D0=B2=20=D0=B1=D0=B0=D0=B7=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hypn0/hypn0_site/services/halftone.py | 21 +++ hypn0/hypn0_site/services/naming.py | 187 ++++++++++++++++++++++ hypn0/hypn0_site/tests.py | 101 +++++++++++- hypn0/hypn0_site/urls.py | 1 + hypn0/hypn0_site/views.py | 105 +++++++++++- hypn0/templates/block/preview.html | 45 ++++-- hypn0/templates/block/publish_status.html | 59 +++++++ 7 files changed, 507 insertions(+), 12 deletions(-) create mode 100644 hypn0/hypn0_site/services/naming.py create mode 100644 hypn0/templates/block/publish_status.html diff --git a/hypn0/hypn0_site/services/halftone.py b/hypn0/hypn0_site/services/halftone.py index d613391..2690032 100644 --- a/hypn0/hypn0_site/services/halftone.py +++ b/hypn0/hypn0_site/services/halftone.py @@ -260,3 +260,24 @@ def generate_halftone_svg( ) return svg_template + + +def prepare_gallery_svg(svg_content: str) -> str: + """ + Модифицирует SVG для долговременного хранения в галерее: + добавляет CSS-правила, чтобы в изолированном контексте () анимация + находилась на паузе по умолчанию и запускалась только при наведении (:hover), + предотвращая перегрузку CPU/GPU браузера при выводе сетки карточек. + """ + if not svg_content: + return svg_content + + 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}" + ) + + if "" in svg_content: + return svg_content.replace("", f"{hover_css}", 1) + + return svg_content diff --git a/hypn0/hypn0_site/services/naming.py b/hypn0/hypn0_site/services/naming.py new file mode 100644 index 0000000..850e913 --- /dev/null +++ b/hypn0/hypn0_site/services/naming.py @@ -0,0 +1,187 @@ +""" +Сервис генерации гипнотических названий для картин галереи. + +Включает: +- Локальный генератор псевдослучайных гипно-заголовков (в стиле Гипножабы, психоделики и мемов). +- Закладку (интерфейс и спецификацию) для будущей интеграции с OpenRouter API (мультимодальные VLM). +""" + +import random +from typing import Optional + + +# Словари для локального генератора гипно-тайтлов +HYPNO_ADJECTIVES = [ + "Мерцающий", + "Астральный", + "Гипнотический", + "Психоделический", + "Пульсирующий", + "Неоновый", + "Трансцендентный", + "Мозговой", + "Ментальный", + "Бессознательный", + "Космический", + "Галлюциногенный", + "Фрактальный", + "Гипножабий", + "Завораживающий", + "Магнетический", + "Спиральный", + "Зыбкий", + "Иллюзорный", + "Онейрический", + "Эфирный", + "Трансовый", + "Квантовый", + "Синтетический", + "Хроматический", +] + +HYPNO_NOUNS = [ + "Транс", + "Пульсар", + "Взгляд", + "Слизень", + "Вихрь", + "Портал", + "Разум", + "Фрактал", + "Сигнал", + "Спектр", + "Резонанс", + "Морок", + "Оазис", + "Омут", + "Лабиринт", + "Импульс", + "Монолит", + "Оракул", + "Вектор", + "Сгусток", + "Каскад", + "Горизонт", + "Мухомор", + "Паттерн", + "Контур", + "Фантом", + "Всплеск", + "Взмах", + "Взгляд", + "Морок", +] + +HYPNO_GENITIVES = [ + "сознания", + "Гипножабы", + "бессознательного", + "пустоты", + "матрицы", + "психо-паутины", + "слизня", + "ноосферы", + "нео-модерна", + "транса", + "глубины", + "эфира", + "плесени", + "измерения", + "пуантилизма", + "субстанции", + "неопределенности", + "континуума", + "кислоты", +] + +HYPNO_POSTFIXES = [ + "#42", + "v3.14", + "MK-Ultra", + "Фаза 7", + "Пси-излучение", + "Альфа", + "Омега", + "Частота 432Гц", + "Протокол 09", + "Ревизия 8", + "Экстракт", + "Слой 9", + "Палата №6", + "Ангар 18", + "Ангар 18", +] + + +def generate_hypno_title(seed: Optional[int] = None) -> str: + """ + Генерирует сочное гипнотическое название для картины на основе словарей. + Примеры: + - «Мерцающий Транс сознания #42» + - «Гипножабий Пульсар психо-паутины» + - «Астральный Взгляд v3.14» + """ + rng = random.Random(seed) + adj = rng.choice(HYPNO_ADJECTIVES) + noun = rng.choice(HYPNO_NOUNS) + + pattern_type = rng.randint(1, 3) + if pattern_type == 1: + genitive = rng.choice(HYPNO_GENITIVES) + postfix = rng.choice(HYPNO_POSTFIXES) + return f"{adj} {noun} {genitive} {postfix}" + elif pattern_type == 2: + genitive = rng.choice(HYPNO_GENITIVES) + return f"{adj} {noun} {genitive}" + else: + postfix = rng.choice(HYPNO_POSTFIXES) + return f"{adj} {noun} {postfix}" + + +# ===================================================================== +# ЗАКЛАДКА: OpenRouter VLM API Integration (Post-MVP / AI Title) +# ===================================================================== +def generate_title_openrouter( + image_bytes: bytes, + *, + api_key: Optional[str] = None, + model: str = "google/gemini-2.5-flash", + system_prompt: str = ( + "Ты — глашатай всемогущей Гипножабы. Придумай короткое (2-4 слова), " + "яркое, иронично-абсурдное и гипнотическое название на русском языке " + "для полутоновой SVG-картины, изображенной на миниатюре. " + "Отвечай ТОЛЬКО названием, без кавычек и вводных слов." + ), + timeout: int = 5, +) -> Optional[str]: + """ + [ЗАКЛАДКА ПОД OPENROUTER API] + + Спецификация интеграции: + 1. Исходное растровое изображение сжимается до thumbnail 256x256 WebP/JPEG. + 2. Кодируется в Base64 (data:image/jpeg;base64,...). + 3. Отправляется POST-запрос на https://openrouter.ai/api/v1/chat/completions: + Payload: { + "model": model, + "messages": [ + {"role": "system", "content": system_prompt}, + { + "role": "user", + "content": [ + {"type": "text", "text": "Дай гипнотическое название для этой картины:"}, + {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{b64_data}"}} + ] + } + ], + "max_tokens": 50, + "temperature": 0.9 + } + 4. При ошибке таймаута, отсутствии OPENROUTER_API_KEY в .env или сбое сети + функция возвращает None, вызывая локальный fallback (generate_hypno_title). + """ + # TODO: Интегрировать requests / httpx при добавлении OPENROUTER_API_KEY в settings.py + if not api_key: + return None + + # Заглушка: при отсутствии реального сетевого коннектора возвращаем None для fallback + return None diff --git a/hypn0/hypn0_site/tests.py b/hypn0/hypn0_site/tests.py index 7420506..cec4dfe 100644 --- a/hypn0/hypn0_site/tests.py +++ b/hypn0/hypn0_site/tests.py @@ -5,7 +5,9 @@ from django.urls import reverse from PIL import Image from .forms import HalftoneGenerateForm -from .services.halftone import encode_to_base36, generate_halftone_svg +from .models import TbHypn0Item, TbVote +from .services.halftone import encode_to_base36, generate_halftone_svg, prepare_gallery_svg +from .services.naming import generate_hypno_title, generate_title_openrouter class HalftoneServiceTests(TestCase): @@ -205,3 +207,100 @@ class HalftoneViewTests(TestCase): self.assertContains(response, " 5) + + # Проверка детерминированности по seed + self.assertEqual(generate_hypno_title(seed=42), title) + + def test_openrouter_stub(self): + res = generate_title_openrouter(b"dummy_bytes") + self.assertIsNone(res) + + +class GalleryPreparationTests(TestCase): + """Тестирование подготовки SVG к галерейному хранению.""" + + def test_prepare_gallery_svg(self): + raw_svg = '' + prepared = prepare_gallery_svg(raw_svg) + self.assertIn("svg:not(:hover)", prepared) + self.assertIn("animation-play-state:paused!important", prepared) + + +class PublishViewTests(TestCase): + """Тестирование эндпоинта публикации кандидата в галерею (/publish).""" + + def setUp(self): + self.client = Client() + self.sample_svg = '' + + def test_publish_without_cookie_returns_slug_protest(self): + response = self.client.post( + reverse("hypn0_site:publish"), + data={ + "svg_content": self.sample_svg, + "shape": "circle", + "cols": "35", + }, + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Мозговые слизняки протестуют!") + self.assertContains(response, "Подчиниться и опубликовать") + self.assertEqual(TbHypn0Item.objects.count(), 0) + + def test_publish_with_cookie_success(self): + vid = "123e4567-e89b-12d3-a456-426614174000" + self.client.cookies["hypn0_vid"] = vid + + response = self.client.post( + reverse("hypn0_site:publish"), + data={ + "svg_content": self.sample_svg, + "shape": "diamond", + "cols": "40", + "max_radius": "6", + "blink": "7", + "rotation": "5", + "scale": "950", + "angle": "10", + "color": "#10b981", + }, + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Уже в галерее транса") + self.assertContains(response, "идет прорастание психо-паутины...") + + # Проверяем запись в БД + self.assertEqual(TbHypn0Item.objects.count(), 1) + item = TbHypn0Item.objects.first() + self.assertTrue(len(item.s_hash_id) >= 6) + self.assertIn(f"#{item.s_hash_id}", response.content.decode("utf-8")) + self.assertEqual(item.i_level, TbHypn0Item.Level.CANDIDATE) + self.assertEqual(item.i_likes_count, 1) + self.assertEqual(item.j_metadata["shape"], "diamond") + self.assertEqual(item.j_metadata["cols"], 40) + self.assertEqual(item.j_metadata["color"], "#10b981") + + # Проверяем авторский голос в TbVote + self.assertEqual(TbVote.objects.count(), 1) + author_vote = TbVote.objects.first() + self.assertEqual(author_vote.k_item, item) + self.assertEqual(author_vote.i_direction, TbVote.Direction.AUTHOR) + + def test_publish_empty_svg_returns_error(self): + self.client.cookies["hypn0_vid"] = "123e4567-e89b-12d3-a456-426614174000" + response = self.client.post( + reverse("hypn0_site:publish"), + data={"svg_content": ""}, + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Нет данных SVG для публикации") + self.assertEqual(TbHypn0Item.objects.count(), 0) diff --git a/hypn0/hypn0_site/urls.py b/hypn0/hypn0_site/urls.py index 0de4db5..ea2f2ac 100644 --- a/hypn0/hypn0_site/urls.py +++ b/hypn0/hypn0_site/urls.py @@ -8,6 +8,7 @@ app_name = "hypn0_site" urlpatterns = [ path("", views.index, name="index"), path("generate", views.generate, name="generate"), + path("publish", views.publish, name="publish"), ] if settings.DEBUG: diff --git a/hypn0/hypn0_site/views.py b/hypn0/hypn0_site/views.py index 2f980f0..75040bb 100644 --- a/hypn0/hypn0_site/views.py +++ b/hypn0/hypn0_site/views.py @@ -1,10 +1,13 @@ +from django.core.files.base import ContentFile from django.http import HttpRequest, HttpResponse from django.shortcuts import render from django.views.decorators.csrf import ensure_csrf_cookie from django.views.decorators.http import require_POST from .forms import HalftoneGenerateForm -from .services.halftone import generate_halftone_svg +from .models import TbHypn0Item +from .services.halftone import generate_halftone_svg, prepare_gallery_svg +from .services.naming import generate_hypno_title @ensure_csrf_cookie @@ -65,5 +68,105 @@ def generate(request: HttpRequest) -> HttpResponse: "block/preview.html", { "svg_content": svg_content, + "form_data": form.cleaned_data, + "primary_color": primary_color, + }, + ) + + +@require_POST +def publish(request: HttpRequest) -> HttpResponse: + """ + HTMX-эндпоинт для сохранения текущей генерации как кандидата в галерею транса. + Требует согласия на отслеживание (Zero-PII cookie hypn0_vid). + """ + svg_content = request.POST.get("svg_content", "").strip() + if not svg_content: + return render( + request, + "block/publish_status.html", + {"error": "Нет данных SVG для публикации. Попробуйте сгенерировать заново."}, + ) + + visitor_uuid = request.COOKIES.get("hypn0_vid") + if not visitor_uuid: + # Мозговые слизняки протестуют: пользователь не дал согласия и не подчинился Гипножабе + return render( + request, + "block/publish_status.html", + { + "not_agreed": True, + "svg_content": svg_content, + "shape": request.POST.get("shape", "circle"), + "cols": request.POST.get("cols", "35"), + "max_radius": request.POST.get("max_radius", "8"), + "blink": request.POST.get("blink", "6"), + "rotation": request.POST.get("rotation", "0"), + "scale": request.POST.get("scale", "980"), + "angle": request.POST.get("angle", "0"), + "color": request.POST.get("color", "#a855ff"), + }, + ) + + # 1. Генерируем гипнотическое название + title = generate_hypno_title() + + # 2. Подготавливаем SVG для галереи (пауза по умолчанию + hover) + gallery_svg = prepare_gallery_svg(svg_content) + svg_bytes = gallery_svg.encode("utf-8") + + # 3. Собираем параметры генерации в метаданные + metadata = { + "shape": request.POST.get("shape", "circle"), + "cols": int(request.POST.get("cols", 35)) if request.POST.get("cols", "").isdigit() else 35, + "max_radius": int(request.POST.get("max_radius", 8)) if request.POST.get("max_radius", "").isdigit() else 8, + "blink": int(request.POST.get("blink", 6)) if request.POST.get("blink", "").isdigit() else 6, + "rotation": int(request.POST.get("rotation", 0)) if request.POST.get("rotation", "").lstrip("-").isdigit() else 0, + "scale": int(request.POST.get("scale", 980)) if request.POST.get("scale", "").isdigit() else 980, + "angle": int(request.POST.get("angle", 0)) if request.POST.get("angle", "").lstrip("-").isdigit() else 0, + "color": request.POST.get("color", "#a855ff"), + } + + try: + svg_file = ContentFile(svg_bytes, name="hypn0.svg") + item = TbHypn0Item( + s_title=title, + file_svg=svg_file, + i_file_size=len(svg_bytes), + j_metadata=metadata, + i_level=TbHypn0Item.Level.CANDIDATE, + is_public=True, + ) + item.save(visitor_uuid_or_fp=visitor_uuid) + except PermissionError: + return render( + request, + "block/publish_status.html", + { + "not_agreed": True, + "svg_content": svg_content, + "shape": request.POST.get("shape", "circle"), + "cols": request.POST.get("cols", "35"), + "max_radius": request.POST.get("max_radius", "8"), + "blink": request.POST.get("blink", "6"), + "rotation": request.POST.get("rotation", "0"), + "scale": request.POST.get("scale", "980"), + "angle": request.POST.get("angle", "0"), + "color": request.POST.get("color", "#a855ff"), + }, + ) + except Exception as e: + return render( + request, + "block/publish_status.html", + {"error": f"Сбой фиксации в трансе: {str(e)}"}, + ) + + return render( + request, + "block/publish_status.html", + { + "success": True, + "item": item, }, ) diff --git a/hypn0/templates/block/preview.html b/hypn0/templates/block/preview.html index fef3f13..5c12882 100644 --- a/hypn0/templates/block/preview.html +++ b/hypn0/templates/block/preview.html @@ -12,15 +12,40 @@ {{ svg_content|safe }} - -
- + +
+
{% endif %} diff --git a/hypn0/templates/block/publish_status.html b/hypn0/templates/block/publish_status.html new file mode 100644 index 0000000..1a4f9a8 --- /dev/null +++ b/hypn0/templates/block/publish_status.html @@ -0,0 +1,59 @@ +{% if not_agreed %} + + + +{% elif success %} + + + +{% elif error %} + + +{% endif %}