mod: ядро (04) SVG-генерация записывается в базу

This commit is contained in:
2026-08-27 00:06:15 +03:00
parent 58c1ee46df
commit 9d3584e96e
7 changed files with 507 additions and 12 deletions
+21
View File
@@ -260,3 +260,24 @@ def generate_halftone_svg(
) )
return svg_template 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
+187
View File
@@ -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
View File
@@ -5,7 +5,9 @@ from django.urls import reverse
from PIL import Image from PIL import Image
from .forms import HalftoneGenerateForm 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): class HalftoneServiceTests(TestCase):
@@ -205,3 +207,100 @@ class HalftoneViewTests(TestCase):
self.assertContains(response, "<svg") self.assertContains(response, "<svg")
self.assertContains(response, 'points="0,-6') self.assertContains(response, 'points="0,-6')
self.assertContains(response, "like-to-gallery") 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)
+1
View File
@@ -8,6 +8,7 @@ app_name = "hypn0_site"
urlpatterns = [ urlpatterns = [
path("", views.index, name="index"), path("", views.index, name="index"),
path("generate", views.generate, name="generate"), path("generate", views.generate, name="generate"),
path("publish", views.publish, name="publish"),
] ]
if settings.DEBUG: if settings.DEBUG:
+104 -1
View File
@@ -1,10 +1,13 @@
from django.core.files.base import ContentFile
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.shortcuts import render from django.shortcuts import render
from django.views.decorators.csrf import ensure_csrf_cookie from django.views.decorators.csrf import ensure_csrf_cookie
from django.views.decorators.http import require_POST from django.views.decorators.http import require_POST
from .forms import HalftoneGenerateForm 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 @ensure_csrf_cookie
@@ -65,5 +68,105 @@ def generate(request: HttpRequest) -> HttpResponse:
"block/preview.html", "block/preview.html",
{ {
"svg_content": svg_content, "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,
}, },
) )
+35 -10
View File
@@ -12,15 +12,40 @@
{{ svg_content|safe }} {{ svg_content|safe }}
</div> </div>
<!-- Плавающие кнопки действий --> <!-- Плавающий блок кандидата в галерею -->
<div class="absolute bottom-6 right-6 flex items-center gap-3 translate-y-20 group-hover:translate-y-0 transition-transform duration-300 pointer-events-none"> <div class="absolute bottom-6 right-6 flex items-center gap-3 translate-y-20 group-hover:translate-y-0 transition-transform duration-300 pointer-events-none z-10">
<button id="like-to-gallery" type="button" title="Нравится!" <div id="gallery-candidate-action" class="pointer-events-auto" x-data="{ isPublishing: false }">
class="pointer-events-auto flex items-center gap-2 bg-emerald-600/90 hover:bg-emerald-600 text-white text-xs font-bold uppercase tracking-wide backdrop-blur-md px-4 py-2.5 rounded-full shadow-xl transition-all hover:scale-105 active:scale-95 cursor-pointer"> <form hx-post="{% url 'hypn0_site:publish' %}"
<span>Кандидат в галерею транса</span> hx-target="#gallery-candidate-action"
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> hx-swap="outerHTML"
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" @submit="isPublishing = true">
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"/> {% csrf_token %}
</svg> <textarea name="svg_content" class="hidden">{{ svg_content }}</textarea>
</button> <input type="hidden" name="shape" value="{% if form_data.shape %}{{ form_data.shape }}{% else %}circle{% endif %}">
<input type="hidden" name="cols" value="{% if form_data.cols %}{{ form_data.cols }}{% else %}35{% endif %}">
<input type="hidden" name="max_radius" value="{% if form_data.max_radius %}{{ form_data.max_radius }}{% else %}8{% endif %}">
<input type="hidden" name="blink" value="{% if form_data.blink is not None %}{{ form_data.blink }}{% else %}6{% endif %}">
<input type="hidden" name="rotation" value="{% if form_data.rotation is not None %}{{ form_data.rotation }}{% else %}0{% endif %}">
<input type="hidden" name="scale" value="{% if form_data.scale %}{{ form_data.scale }}{% else %}980{% endif %}">
<input type="hidden" name="angle" value="{% if form_data.angle is not None %}{{ form_data.angle }}{% else %}0{% endif %}">
<input type="hidden" name="color" value="{% if primary_color %}{{ primary_color }}{% else %}#a855ff{% endif %}">
<button id="like-to-gallery" type="submit" title="Отправить в галерею"
:disabled="isPublishing"
class="group flex items-center gap-2 bg-emerald-600/90 hover:bg-emerald-600 text-white text-xs font-bold uppercase tracking-wide backdrop-blur-md px-4 py-2.5 rounded-full shadow-xl transition-all hover:scale-105 active:scale-95 disabled:opacity-60 disabled:scale-100 disabled:pointer-events-none cursor-pointer">
<template x-if="isPublishing">
<svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"></path>
</svg>
</template>
<span x-text="isPublishing ? 'Проникновение в транс...' : 'Кандидат в галерею транса'">Кандидат в галерею транса</span>
<svg x-show="!isPublishing" class="w-5 h-5 transition-transform group-hover:scale-110" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"/>
</svg>
</button>
</form>
</div>
</div> </div>
{% endif %} {% endif %}
+59
View File
@@ -0,0 +1,59 @@
{% if not_agreed %}
<!-- Сообщение от протестующих мозговых слизней (Zero-PII / отсутствие куки) -->
<div id="gallery-candidate-action" class="pointer-events-auto bg-amber-500/95 dark:bg-amber-600/95 text-stone-900 dark:text-stone-100 p-4 rounded-2xl shadow-2xl backdrop-blur-md max-w-sm border border-amber-300 dark:border-amber-400 animate-in fade-in zoom-in-95 duration-200">
<div class="flex items-start gap-3">
<div class="w-8 h-8 shrink-0 flex items-center justify-center bg-amber-600 dark:bg-amber-700 text-white rounded-full text-lg shadow-inner">
🐌
</div>
<div class="space-y-2">
<h4 class="font-bold text-xs uppercase tracking-wide text-amber-950 dark:text-amber-100">Мозговые слизняки протестуют!</h4>
<p class="text-xs leading-relaxed opacity-95">
Они наябедничали Гипножабе, что вы ещё не подчинились и сопротивляетесь её воле. Только истинно подчинившийся разумом удостаивается чести попасть в галерею транса!
</p>
<form hx-post="{% url 'hypn0_site:publish' %}"
hx-target="#gallery-candidate-action"
hx-swap="outerHTML"
class="pt-1 flex items-center gap-2">
{% csrf_token %}
<textarea name="svg_content" class="hidden">{{ svg_content }}</textarea>
<input type="hidden" name="shape" value="{{ shape }}">
<input type="hidden" name="cols" value="{{ cols }}">
<input type="hidden" name="max_radius" value="{{ max_radius }}">
<input type="hidden" name="blink" value="{{ blink }}">
<input type="hidden" name="rotation" value="{{ rotation }}">
<input type="hidden" name="scale" value="{{ scale }}">
<input type="hidden" name="angle" value="{{ angle }}">
<input type="hidden" name="color" value="{{ color }}">
<button type="submit"
@click="const vid = crypto.randomUUID(); localStorage.setItem('hypn0_vid', vid); document.cookie = 'hypn0_vid=' + vid + '; path=/; max-age=63072000; SameSite=Lax'; const popup = document.getElementById('allow-tracking-popup'); if (popup) popup.style.display = 'none';"
class="bg-stone-900 hover:bg-black text-white dark:bg-stone-100 dark:hover:bg-white dark:text-stone-900 text-xs font-bold px-3 py-1.5 rounded-lg shadow transition-all hover:scale-105 active:scale-95 cursor-pointer">
Подчиниться и опубликовать
</button>
</form>
</div>
</div>
</div>
{% elif success %}
<!-- Успешная фиксация кандидата в галерее транса -->
<div id="gallery-candidate-action" class="pointer-events-auto flex flex-col sm:flex-row items-start sm:items-center gap-2.5 bg-purple-950/90 hover:bg-purple-950 text-purple-100 text-xs font-medium px-4 py-2.5 rounded-2xl sm:rounded-full shadow-2xl backdrop-blur-md border border-purple-500/40 animate-in fade-in duration-300">
<div class="flex items-center gap-2">
<span class="relative flex h-2.5 w-2.5">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-emerald-500"></span>
</span>
<span class="font-semibold text-emerald-300">Уже в галерее транса</span>
</div>
<span class="hidden sm:inline opacity-40"></span>
<span class="opacity-90 italic">идет прорастание психо-паутины...</span>
<span class="font-mono text-[11px] bg-purple-900/80 text-purple-300 border border-purple-400/30 px-2 py-0.5 rounded-md">#{{ item.s_hash_id }}</span>
</div>
{% elif error %}
<!-- Ошибка публикации -->
<div id="gallery-candidate-action" class="pointer-events-auto flex items-center gap-3 bg-rose-600/90 text-white text-xs font-medium px-4 py-2.5 rounded-full shadow-xl backdrop-blur-md border border-rose-400/30">
<span>{{ error }}</span>
</div>
{% endif %}