mod: ядро (04) SVG-генерация записывается в базу
This commit is contained in:
@@ -260,3 +260,24 @@ def generate_halftone_svg(
|
||||
)
|
||||
|
||||
return svg_template
|
||||
|
||||
|
||||
def prepare_gallery_svg(svg_content: str) -> str:
|
||||
"""
|
||||
Модифицирует SVG для долговременного хранения в галерее:
|
||||
добавляет CSS-правила, чтобы в изолированном контексте (<img>) анимация
|
||||
находилась на паузе по умолчанию и запускалась только при наведении (: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 "</style>" in svg_content:
|
||||
return svg_content.replace("</style>", f"{hover_css}</style>", 1)
|
||||
|
||||
return svg_content
|
||||
|
||||
@@ -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
|
||||
+100
-1
@@ -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, "<svg")
|
||||
self.assertContains(response, 'points="0,-6')
|
||||
self.assertContains(response, "like-to-gallery")
|
||||
|
||||
|
||||
class NamingServiceTests(TestCase):
|
||||
"""Тестирование сервиса гипнотических названий (services/naming.py)."""
|
||||
|
||||
def test_generate_hypno_title(self):
|
||||
title = generate_hypno_title(seed=42)
|
||||
self.assertIsInstance(title, str)
|
||||
self.assertTrue(len(title) > 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 = '<svg><style>.shape{animation:noise 1s}</style><g></g></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 = '<svg xmlns="http://www.w3.org/2000/svg"><style>.shape{color:red}</style><g><circle/></g></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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
+104
-1
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user