add: галерея (1) одиночная картинка
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import io
|
||||
import math
|
||||
import random
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from typing import BinaryIO, Union
|
||||
|
||||
@@ -281,3 +282,101 @@ def prepare_gallery_svg(svg_content: str) -> str:
|
||||
return svg_content.replace("</style>", f"{hover_css}</style>", 1)
|
||||
|
||||
return svg_content
|
||||
|
||||
|
||||
def prepare_active_svg(svg_content: str) -> str:
|
||||
"""
|
||||
Возвращает полностью активный SVG без паузы анимации вне ховера
|
||||
для страницы детального просмотра и скачивания пользователем.
|
||||
"""
|
||||
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}"
|
||||
)
|
||||
return svg_content.replace(hover_css, "")
|
||||
|
||||
|
||||
def analyze_svg_structure(svg_content: str) -> dict:
|
||||
"""
|
||||
Анализирует разметку SVG и возвращает наукообразную детальную статистику
|
||||
по геометрическим примитивам, классам, анимациям и энтропии данных.
|
||||
"""
|
||||
if not svg_content:
|
||||
return {
|
||||
"total_bytes": 0,
|
||||
"total_kb": 0.0,
|
||||
"total_oscillators": 0,
|
||||
"use_count": 0,
|
||||
"circle_count": 0,
|
||||
"rect_count": 0,
|
||||
"polygon_count": 0,
|
||||
"path_count": 0,
|
||||
"defs_count": 0,
|
||||
"groups_count": 0,
|
||||
"keyframes_count": 0,
|
||||
"classes_count": 0,
|
||||
"viewbox": "0 0 1000 1000",
|
||||
"width": 1000,
|
||||
"height": 1000,
|
||||
"aspect_ratio": "1:1",
|
||||
"density": 0.0,
|
||||
"bytes_per_quantum": 0.0,
|
||||
}
|
||||
|
||||
total_bytes = len(svg_content.encode("utf-8"))
|
||||
|
||||
# Подсчет векторных сущностей
|
||||
use_count = len(re.findall(r"<use\b", svg_content))
|
||||
circle_count = len(re.findall(r"<circle\b", svg_content))
|
||||
rect_count = len(re.findall(r"<rect\b", svg_content))
|
||||
polygon_count = len(re.findall(r"<polygon\b", svg_content))
|
||||
path_count = len(re.findall(r"<path\b", svg_content))
|
||||
defs_count = circle_count + rect_count + polygon_count + path_count
|
||||
|
||||
# Группы и классы
|
||||
groups_count = len(re.findall(r"<g\b", svg_content))
|
||||
keyframes_count = len(re.findall(r"@keyframes\b", svg_content))
|
||||
classes = set(re.findall(r"\.([a-zA-Z0-9_-]+)\s*\{", svg_content))
|
||||
classes_count = len(classes)
|
||||
|
||||
# ViewBox и геометрия
|
||||
vb_match = re.search(r'viewBox=["\']([^"\']+)["\']', svg_content)
|
||||
viewbox = vb_match.group(1) if vb_match else "0 0 1000 1000"
|
||||
vb_parts = viewbox.split()
|
||||
try:
|
||||
width = int(float(vb_parts[2])) if len(vb_parts) >= 3 else 1000
|
||||
height = int(float(vb_parts[3])) if len(vb_parts) >= 4 else 1000
|
||||
except (ValueError, IndexError):
|
||||
width, height = 1000, 1000
|
||||
|
||||
gcd_val = math.gcd(width, height)
|
||||
aspect_ratio = f"{width // gcd_val}:{height // gcd_val}" if gcd_val > 0 else "1:1"
|
||||
|
||||
# Наукообразные показатели
|
||||
total_oscillators = use_count if use_count > 0 else defs_count
|
||||
bytes_per_quantum = round(total_bytes / max(total_oscillators, 1), 1)
|
||||
density = round(total_oscillators / max((width * height) / 100000.0, 1.0), 2)
|
||||
|
||||
return {
|
||||
"total_bytes": total_bytes,
|
||||
"total_kb": round(total_bytes / 1024, 2),
|
||||
"total_oscillators": total_oscillators,
|
||||
"use_count": use_count,
|
||||
"circle_count": circle_count,
|
||||
"rect_count": rect_count,
|
||||
"polygon_count": polygon_count,
|
||||
"path_count": path_count,
|
||||
"defs_count": defs_count,
|
||||
"groups_count": groups_count,
|
||||
"keyframes_count": keyframes_count,
|
||||
"classes_count": classes_count,
|
||||
"viewbox": viewbox,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"aspect_ratio": aspect_ratio,
|
||||
"density": density,
|
||||
"bytes_per_quantum": bytes_per_quantum,
|
||||
}
|
||||
|
||||
+265
-1
@@ -1,4 +1,5 @@
|
||||
import io
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import Client, TestCase
|
||||
from django.urls import reverse
|
||||
@@ -6,7 +7,13 @@ from PIL import Image
|
||||
|
||||
from .forms import HalftoneGenerateForm
|
||||
from .models import TbHypn0Item, TbVote
|
||||
from .services.halftone import encode_to_base36, generate_halftone_svg, prepare_gallery_svg
|
||||
from .services.halftone import (
|
||||
analyze_svg_structure,
|
||||
encode_to_base36,
|
||||
generate_halftone_svg,
|
||||
prepare_active_svg,
|
||||
prepare_gallery_svg,
|
||||
)
|
||||
from .services.naming import generate_hypno_title, generate_title_openrouter
|
||||
|
||||
|
||||
@@ -304,3 +311,260 @@ class PublishViewTests(TestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Нет данных SVG для публикации")
|
||||
self.assertEqual(TbHypn0Item.objects.count(), 0)
|
||||
|
||||
|
||||
class SvgAnalysisAndActiveSvgTests(TestCase):
|
||||
"""Тестирование анализа структуры SVG и очистки от паузы."""
|
||||
|
||||
def test_prepare_active_svg(self):
|
||||
gallery_svg = '<svg><style>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}</style><g></g></svg>'
|
||||
active = prepare_active_svg(gallery_svg)
|
||||
self.assertNotIn("animation-play-state:paused!important", active)
|
||||
|
||||
def test_analyze_svg_structure(self):
|
||||
svg = (
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 600">'
|
||||
'<style>.a0{--d:0s}.a1{--d:1s}@keyframes noise{0%{opacity:1}100%{opacity:0.5}}</style>'
|
||||
'<defs><circle id="s0" r="5"/><rect id="s1" width="10" height="10"/></defs>'
|
||||
'<g id="hypn0-canvas">'
|
||||
'<g class="a0"><use href="#s0" x="10" y="10"/><use href="#s0" x="20" y="20"/></g>'
|
||||
'<g class="a1"><use href="#s1" x="30" y="30"/></g>'
|
||||
'</g>'
|
||||
'</svg>'
|
||||
)
|
||||
stats = analyze_svg_structure(svg)
|
||||
self.assertEqual(stats["use_count"], 3)
|
||||
self.assertEqual(stats["total_oscillators"], 3)
|
||||
self.assertEqual(stats["defs_count"], 2)
|
||||
self.assertEqual(stats["circle_count"], 1)
|
||||
self.assertEqual(stats["rect_count"], 1)
|
||||
self.assertEqual(stats["groups_count"], 3)
|
||||
self.assertEqual(stats["keyframes_count"], 1)
|
||||
self.assertEqual(stats["viewbox"], "0 0 800 600")
|
||||
self.assertEqual(stats["width"], 800)
|
||||
self.assertEqual(stats["height"], 600)
|
||||
self.assertEqual(stats["aspect_ratio"], "4:3")
|
||||
self.assertTrue(stats["total_bytes"] > 0)
|
||||
|
||||
|
||||
class GalleryDetailAndDownloadTests(TestCase):
|
||||
"""Тестирование страниц детального просмотра картины, скачивания и голосования."""
|
||||
|
||||
def setUp(self):
|
||||
self.client = Client()
|
||||
self.vid = "123e4567-e89b-12d3-a456-426614174000"
|
||||
svg_code = (
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000">'
|
||||
'<style>.a0{--d:0s}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}</style>'
|
||||
'<defs><circle id="s0" r="8"/></defs>'
|
||||
'<g id="hypn0-canvas"><g class="a0"><use href="#s0" x="50" y="50"/></g></g>'
|
||||
'</svg>'
|
||||
)
|
||||
self.item = TbHypn0Item(
|
||||
s_title="Астральный Транс Сознания #42",
|
||||
file_svg=ContentFile(svg_code.encode("utf-8"), name="test_item.svg"),
|
||||
i_file_size=len(svg_code),
|
||||
j_metadata={
|
||||
"shape": "circle",
|
||||
"cols": 35,
|
||||
"max_radius": 8,
|
||||
"blink": 6,
|
||||
"rotation": 0,
|
||||
"scale": 980,
|
||||
"angle": 0,
|
||||
"color": "#a855ff",
|
||||
},
|
||||
i_level=TbHypn0Item.Level.CANDIDATE,
|
||||
is_public=True,
|
||||
)
|
||||
self.item.save(visitor_uuid_or_fp=self.vid)
|
||||
|
||||
def test_gallery_detail_view_success(self):
|
||||
response = self.client.get(reverse("hypn0_site:gallery_detail", kwargs={"hash_id": self.item.s_hash_id}))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Астральный Транс Сознания #42")
|
||||
self.assertContains(response, f"#{self.item.s_hash_id}")
|
||||
self.assertContains(response, "Векторная анатомия")
|
||||
self.assertContains(response, "Синтез психо-сетки")
|
||||
self.assertContains(response, "Астральный паспорт")
|
||||
self.assertContains(response, "Копировать SVG-код")
|
||||
self.assertContains(response, "Скачать .svg")
|
||||
|
||||
# Проверка инкремента просмотров
|
||||
self.item.refresh_from_db()
|
||||
self.assertGreaterEqual(self.item.i_views_count, 2)
|
||||
|
||||
def test_gallery_detail_404_on_invalid_hash(self):
|
||||
response = self.client.get(reverse("hypn0_site:gallery_detail", kwargs={"hash_id": "nonexistent999"}))
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_gallery_download_view_success(self):
|
||||
response = self.client.get(reverse("hypn0_site:gallery_download", kwargs={"hash_id": self.item.s_hash_id}))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response["Content-Type"], "image/svg+xml")
|
||||
self.assertIn(f'filename="hypn0-{self.item.s_hash_id}.svg"', response["Content-Disposition"])
|
||||
# Должен быть чистый активный SVG (без паузы анимации)
|
||||
self.assertNotIn("animation-play-state:paused!important", response.content.decode("utf-8"))
|
||||
|
||||
def test_gallery_vote_with_cookie(self):
|
||||
# Новый посетитель
|
||||
voter_vid = "987e6543-e21b-12d3-a456-426614174999"
|
||||
self.client.cookies["hypn0_vid"] = voter_vid
|
||||
response = self.client.post(reverse("hypn0_site:gallery_vote", kwargs={"hash_id": self.item.s_hash_id}))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.item.refresh_from_db()
|
||||
self.assertEqual(self.item.i_likes_count, 2)
|
||||
|
||||
def test_gallery_vote_without_cookie_returns_notice(self):
|
||||
client = Client()
|
||||
response = client.post(reverse("hypn0_site:gallery_vote", kwargs={"hash_id": self.item.s_hash_id}))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Подчинитесь воле Гипножабы!")
|
||||
|
||||
|
||||
class GalleryRandomAndNavigationTests(TestCase):
|
||||
"""Тестирование случайной навигации и пула хэшей (/gallery/random)."""
|
||||
|
||||
def setUp(self):
|
||||
self.client = Client()
|
||||
self.vid = "123e4567-e89b-12d3-a456-426614174000"
|
||||
|
||||
# Создаем 3 картины в галерее
|
||||
self.items = []
|
||||
for i in range(3):
|
||||
svg_code = f'<svg><g id="item_{i}"></g></svg>'
|
||||
item = TbHypn0Item(
|
||||
s_title=f"Гипно Картина #{i}",
|
||||
file_svg=ContentFile(svg_code.encode("utf-8"), name=f"test_item_{i}.svg"),
|
||||
i_file_size=len(svg_code),
|
||||
j_metadata={"cols": 30 + i},
|
||||
is_public=True,
|
||||
)
|
||||
item.save(visitor_uuid_or_fp=self.vid)
|
||||
self.items.append(item)
|
||||
|
||||
def test_gallery_random_direct_redirect(self):
|
||||
response = self.client.get(reverse("hypn0_site:gallery_random"))
|
||||
self.assertEqual(response.status_code, 302)
|
||||
all_hashes = [it.s_hash_id for it in self.items]
|
||||
self.assertTrue(any(h in response.url for h in all_hashes))
|
||||
|
||||
def test_gallery_random_json_pool(self):
|
||||
response = self.client.get(reverse("hypn0_site:gallery_random"), data={"format": "json", "limit": 2})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
self.assertIn("next_hash_id", data)
|
||||
self.assertIn("pool", data)
|
||||
self.assertEqual(len(data["pool"]), 2)
|
||||
all_hashes = {it.s_hash_id for it in self.items}
|
||||
self.assertIn(data["next_hash_id"], all_hashes)
|
||||
|
||||
def test_gallery_random_exclude_filters_seen(self):
|
||||
exclude_hashes = [self.items[0].s_hash_id, self.items[1].s_hash_id]
|
||||
response = self.client.get(
|
||||
reverse("hypn0_site:gallery_random"),
|
||||
data={"format": "json", "exclude": ",".join(exclude_hashes)},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
self.assertEqual(data["next_hash_id"], self.items[2].s_hash_id)
|
||||
|
||||
def test_gallery_random_exclude_all_loops_circle(self):
|
||||
# Если исключены все 3 картины, кольцо замыкается
|
||||
exclude_hashes = [it.s_hash_id for it in self.items]
|
||||
response = self.client.get(
|
||||
reverse("hypn0_site:gallery_random"),
|
||||
data={
|
||||
"format": "json",
|
||||
"exclude": ",".join(exclude_hashes),
|
||||
"current": self.items[0].s_hash_id,
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
self.assertIsNotNone(data["next_hash_id"])
|
||||
# Текущий элемент исключен, выбран один из оставшихся
|
||||
self.assertIn(data["next_hash_id"], [self.items[1].s_hash_id, self.items[2].s_hash_id])
|
||||
|
||||
def test_gallery_random_empty_database(self):
|
||||
TbHypn0Item.objects.all().delete()
|
||||
# Direct visit redirects to index
|
||||
response = self.client.get(reverse("hypn0_site:gallery_random"))
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.url, reverse("hypn0_site:index"))
|
||||
|
||||
# JSON returns null next_hash_id
|
||||
json_resp = self.client.get(reverse("hypn0_site:gallery_random"), data={"format": "json"})
|
||||
self.assertEqual(json_resp.status_code, 200)
|
||||
self.assertIsNone(json_resp.json()["next_hash_id"])
|
||||
self.assertEqual(json_resp.json()["pool"], [])
|
||||
|
||||
|
||||
class UnconsciousMatrixTests(TestCase):
|
||||
"""Тестирование Матрицы бессознательного (build_unconscious_matrix) и навигации."""
|
||||
|
||||
def setUp(self):
|
||||
self.client = Client()
|
||||
self.vid = "123e4567-e89b-12d3-a456-426614174000"
|
||||
|
||||
# Создаем 5 картин
|
||||
self.items = []
|
||||
for i in range(5):
|
||||
svg_code = f'<svg><circle id="dot_{i}"/></svg>'
|
||||
item = TbHypn0Item(
|
||||
s_title=f"Тестовый транс #{i}",
|
||||
file_svg=ContentFile(svg_code.encode("utf-8"), name=f"test_matrix_{i}.svg"),
|
||||
i_file_size=len(svg_code),
|
||||
j_metadata={"cols": 20 + i},
|
||||
is_public=True,
|
||||
)
|
||||
item.save(visitor_uuid_or_fp=self.vid)
|
||||
self.items.append(item)
|
||||
|
||||
def test_build_unconscious_matrix_basic(self):
|
||||
from hypn0_site.views import build_unconscious_matrix
|
||||
|
||||
current = self.items[0].s_hash_id
|
||||
matrix_items, prev_h, next_h, prev_url, next_url, seed = build_unconscious_matrix(current, None)
|
||||
|
||||
self.assertEqual(len(matrix_items), 5)
|
||||
# Проверяем, что current отмечен как is_current
|
||||
current_node = [n for n in matrix_items if n["s_hash_id"] == current][0]
|
||||
self.assertTrue(current_node["is_current"])
|
||||
|
||||
# Другие узлы не current
|
||||
other_nodes = [n for n in matrix_items if n["s_hash_id"] != current]
|
||||
self.assertTrue(all(not n["is_current"] for n in other_nodes))
|
||||
|
||||
# Ссылки содержат seed
|
||||
self.assertIn(f"seed={seed}", prev_url)
|
||||
self.assertIn(f"seed={seed}", next_url)
|
||||
|
||||
def test_matrix_stability_with_fixed_seed(self):
|
||||
from hypn0_site.views import build_unconscious_matrix
|
||||
|
||||
root_hash = self.items[2].s_hash_id
|
||||
fixed_seed = f"42109_{root_hash}"
|
||||
|
||||
# Первый запрос
|
||||
m1, prev1, next1, _, _, seed1 = build_unconscious_matrix(self.items[0].s_hash_id, fixed_seed)
|
||||
# Второй запрос с тем же seed на другую картину
|
||||
m2, prev2, next2, _, _, seed2 = build_unconscious_matrix(self.items[1].s_hash_id, fixed_seed)
|
||||
|
||||
self.assertEqual(seed1, fixed_seed)
|
||||
self.assertEqual(seed2, fixed_seed)
|
||||
|
||||
# Состав и порядок хэшей в матрице должны быть идентичны
|
||||
hashes1 = [n["s_hash_id"] for n in m1]
|
||||
hashes2 = [n["s_hash_id"] for n in m2]
|
||||
self.assertEqual(hashes1, hashes2)
|
||||
|
||||
def test_gallery_detail_renders_matrix(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)
|
||||
self.assertContains(response, "Матрица бессознательного")
|
||||
self.assertContains(response, "5 узлов")
|
||||
self.assertContains(response, f"#{item.s_hash_id}")
|
||||
self.assertContains(response, "btn-nav-prev")
|
||||
self.assertContains(response, "btn-nav-next")
|
||||
|
||||
@@ -9,6 +9,11 @@ urlpatterns = [
|
||||
path("", views.index, name="index"),
|
||||
path("generate", views.generate, name="generate"),
|
||||
path("publish", views.publish, name="publish"),
|
||||
path("gallery/random", views.gallery_random, name="gallery_random"),
|
||||
path("gallery/random-pool", views.gallery_random, name="gallery_random_pool"),
|
||||
path("gallery/<str:hash_id>", views.gallery_detail, name="gallery_detail"),
|
||||
path("gallery/<str:hash_id>/download", views.gallery_download, name="gallery_download"),
|
||||
path("gallery/<str:hash_id>/vote", views.gallery_vote, name="gallery_vote"),
|
||||
]
|
||||
|
||||
if settings.DEBUG:
|
||||
|
||||
+279
-4
@@ -1,12 +1,21 @@
|
||||
import hashlib
|
||||
import random
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.files.base import ContentFile
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import render
|
||||
from django.http import Http404, HttpRequest, HttpResponse, JsonResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.views.decorators.csrf import ensure_csrf_cookie
|
||||
from django.views.decorators.http import require_POST
|
||||
|
||||
from .forms import HalftoneGenerateForm
|
||||
from .models import TbHypn0Item
|
||||
from .services.halftone import generate_halftone_svg, prepare_gallery_svg
|
||||
from .models import TbHypn0Item, TbVote
|
||||
from .services.halftone import (
|
||||
analyze_svg_structure,
|
||||
generate_halftone_svg,
|
||||
prepare_active_svg,
|
||||
prepare_gallery_svg,
|
||||
)
|
||||
from .services.naming import generate_hypno_title
|
||||
|
||||
|
||||
@@ -170,3 +179,269 @@ def publish(request: HttpRequest) -> HttpResponse:
|
||||
"item": item,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def gallery_random(request: HttpRequest) -> HttpResponse:
|
||||
"""
|
||||
Быстрый эндпоинт для случайной навигации по галерее ("в транс") и предзагрузки пула хэшей.
|
||||
Принимает список уже просмотренных хэшей в параметре `exclude` (через запятую) и текущий хэш `current`.
|
||||
|
||||
Архитектура выборки:
|
||||
- Запрашивает только плоский список индексированных строк `s_hash_id` (Index Only Scan).
|
||||
- Перемешивание и срез происходят в памяти Python за доли миллисекунды без тяжелого `ORDER BY RANDOM()`.
|
||||
- Если все исключенные работы покрыли базу, фильтр сбрасывается для замыкания кольца.
|
||||
|
||||
========================================================================================
|
||||
МАСШТАБИРОВАНИЕ НА 1 000 000+ ЗАПИСЕЙ (FUTURE SCALING NOTE):
|
||||
При достижении миллионов строк в базе операция exclude(s_hash_id__in=...) может нагружать СУБД.
|
||||
Для ультра-высоких нагрузок переключить на алгоритм:
|
||||
1. count = TbHypn0Item.objects.filter(is_public=True).count()
|
||||
2. random_offset = random.randint(0, max(0, count - slice_size))
|
||||
3. pool_slice = list(TbHypn0Item.objects.filter(is_public=True)
|
||||
.values_list('s_hash_id', flat=True)[random_offset : random_offset + slice_size])
|
||||
4. candidates = [h for h in pool_slice if h not in exclude_set and h != current]
|
||||
5. return random.sample(candidates, min(len(candidates), limit))
|
||||
========================================================================================
|
||||
"""
|
||||
exclude_raw = request.GET.get("exclude", "")
|
||||
exclude_hashes = set(h.strip() for h in exclude_raw.split(",") if h.strip())
|
||||
current_hash = request.GET.get("current", "").strip()
|
||||
if current_hash:
|
||||
exclude_hashes.add(current_hash)
|
||||
|
||||
try:
|
||||
limit = min(max(int(request.GET.get("limit", 20)), 1), 50)
|
||||
except (ValueError, TypeError):
|
||||
limit = 20
|
||||
|
||||
base_qs = TbHypn0Item.objects.filter(is_public=True)
|
||||
|
||||
# 1. Попытка выбрать кандидатов среди еще не просмотренных
|
||||
candidate_hashes = list(
|
||||
base_qs.exclude(s_hash_id__in=exclude_hashes).values_list("s_hash_id", flat=True)[:300]
|
||||
)
|
||||
|
||||
# 2. Если все просмотрены (или база меньше истории) — замыкаем кольцо, исключая только текущий
|
||||
if not candidate_hashes and current_hash:
|
||||
candidate_hashes = list(
|
||||
base_qs.exclude(s_hash_id=current_hash).values_list("s_hash_id", flat=True)[:300]
|
||||
)
|
||||
|
||||
# 3. Крайний случай — берем любые доступные
|
||||
if not candidate_hashes:
|
||||
candidate_hashes = list(base_qs.values_list("s_hash_id", flat=True)[:300])
|
||||
|
||||
if candidate_hashes:
|
||||
random.shuffle(candidate_hashes)
|
||||
pool = candidate_hashes[:limit]
|
||||
next_hash = pool[0]
|
||||
else:
|
||||
pool = []
|
||||
next_hash = None
|
||||
|
||||
# Определение формата ответа
|
||||
is_json = (
|
||||
request.GET.get("format") == "json"
|
||||
or request.headers.get("Accept") == "application/json"
|
||||
or request.headers.get("X-Requested-With") == "XMLHttpRequest"
|
||||
)
|
||||
|
||||
if is_json:
|
||||
return JsonResponse({"next_hash_id": next_hash, "pool": pool})
|
||||
|
||||
if next_hash:
|
||||
return redirect("hypn0_site:gallery_detail", hash_id=next_hash)
|
||||
|
||||
return redirect("hypn0_site:index")
|
||||
|
||||
|
||||
def build_unconscious_matrix(current_hash: str, seed_param: str | None) -> tuple[list[dict], str, str, str, str, str]:
|
||||
"""
|
||||
Формирует "Матрицу бессознательного" (до 200 псевдослучайных узлов) вокруг корневой картины.
|
||||
|
||||
Формат seed: "<NUM>_<ORIGIN_HASH>", например "74291_a1b2c3".
|
||||
Алгоритм:
|
||||
- При первом заходе без seed генерируется seed_num и origin_hash = current_hash.
|
||||
- Выбираются -99 элементов слева и +100 элементов справа от origin_hash (всего до 200).
|
||||
- Для N <= 200 перемешиваются все доступные картины, а origin_hash помещается в центр.
|
||||
- Для текущего элемента current_hash определяются prev_hash и next_hash по кольцу.
|
||||
"""
|
||||
seed_num = None
|
||||
origin_hash = current_hash
|
||||
|
||||
if seed_param:
|
||||
parts = str(seed_param).split("_", 1)
|
||||
try:
|
||||
seed_num = int(parts[0])
|
||||
if len(parts) > 1 and parts[1].strip():
|
||||
origin_hash = parts[1].strip()
|
||||
except (ValueError, TypeError):
|
||||
seed_num = None
|
||||
|
||||
if seed_num is None:
|
||||
seed_num = random.randint(10000, 99999)
|
||||
origin_hash = current_hash
|
||||
|
||||
effective_seed = f"{seed_num}_{origin_hash}"
|
||||
rng = random.Random(seed_num)
|
||||
|
||||
# 1. Запрашиваем все публичные хэши
|
||||
all_hashes = list(TbHypn0Item.objects.filter(is_public=True).values_list("s_hash_id", flat=True))
|
||||
|
||||
if not all_hashes:
|
||||
all_hashes = [current_hash]
|
||||
|
||||
if origin_hash not in all_hashes:
|
||||
origin_hash = current_hash
|
||||
|
||||
other_hashes = [h for h in all_hashes if h != origin_hash]
|
||||
|
||||
if len(other_hashes) <= 199:
|
||||
rng.shuffle(other_hashes)
|
||||
mid_idx = len(other_hashes) // 2
|
||||
trail = other_hashes[:mid_idx] + [origin_hash] + other_hashes[mid_idx:]
|
||||
else:
|
||||
chosen_others = rng.sample(other_hashes, 199)
|
||||
left_part = chosen_others[:99]
|
||||
right_part = chosen_others[99:]
|
||||
trail = left_part + [origin_hash] + right_part
|
||||
|
||||
# Гарантия наличия current_hash в trail
|
||||
if current_hash not in trail:
|
||||
trail.append(current_hash)
|
||||
|
||||
idx = trail.index(current_hash)
|
||||
total = len(trail)
|
||||
prev_hash = trail[(idx - 1) % total]
|
||||
next_hash = trail[(idx + 1) % total]
|
||||
|
||||
prev_url = f"/gallery/{prev_hash}?seed={effective_seed}"
|
||||
next_url = f"/gallery/{next_hash}?seed={effective_seed}"
|
||||
|
||||
matrix_items = [
|
||||
{
|
||||
"s_hash_id": h,
|
||||
"is_current": (h == current_hash),
|
||||
"url": f"/gallery/{h}?seed={effective_seed}",
|
||||
}
|
||||
for h in trail
|
||||
]
|
||||
|
||||
return matrix_items, prev_hash, next_hash, prev_url, next_url, effective_seed
|
||||
|
||||
|
||||
def gallery_detail(request: HttpRequest, hash_id: str) -> HttpResponse:
|
||||
"""
|
||||
Страница детального просмотра и шеринга картины из галереи транса.
|
||||
Выводит активный живой SVG, кнопки скачивания/копирования, наукообразную телеметрию
|
||||
и интерактивную Матрицу бессознательного (персональный поток хэшей по seed).
|
||||
"""
|
||||
item = get_object_or_404(TbHypn0Item, s_hash_id=hash_id, is_public=True)
|
||||
|
||||
# Инкремент просмотров
|
||||
item.increment_views()
|
||||
|
||||
# Считывание содержимого SVG
|
||||
svg_content = ""
|
||||
if item.file_svg:
|
||||
try:
|
||||
with item.file_svg.open("r") as f:
|
||||
svg_content = f.read()
|
||||
if isinstance(svg_content, bytes):
|
||||
svg_content = svg_content.decode("utf-8")
|
||||
except Exception:
|
||||
svg_content = ""
|
||||
|
||||
active_svg = prepare_active_svg(svg_content)
|
||||
svg_stats = analyze_svg_structure(svg_content)
|
||||
|
||||
# Авторский отпечаток
|
||||
author_vote = item.votes.filter(i_direction=TbVote.Direction.AUTHOR).first()
|
||||
author_fp = author_vote.s_fingerprint if author_vote else None
|
||||
|
||||
# Проверка, голосовал ли текущий посетитель
|
||||
user_voted = False
|
||||
visitor_uuid = request.COOKIES.get("hypn0_vid")
|
||||
if visitor_uuid and item.pk:
|
||||
fp = hashlib.sha256(f"{visitor_uuid}:{settings.SECRET_KEY}".encode()).hexdigest()
|
||||
user_voted = item.votes.filter(s_fingerprint=fp).exists()
|
||||
|
||||
# Построение матрицы бессознательного
|
||||
seed_param = request.GET.get("seed")
|
||||
matrix_items, prev_hash, next_hash, prev_url, next_url, seed = build_unconscious_matrix(
|
||||
current_hash=item.s_hash_id, seed_param=seed_param
|
||||
)
|
||||
|
||||
context = {
|
||||
"item": item,
|
||||
"active_svg": active_svg,
|
||||
"raw_svg": svg_content,
|
||||
"svg_stats": svg_stats,
|
||||
"author_fp": author_fp,
|
||||
"user_voted": user_voted,
|
||||
"metadata": item.j_metadata or {},
|
||||
"matrix_items": matrix_items,
|
||||
"prev_hash": prev_hash,
|
||||
"next_hash": next_hash,
|
||||
"prev_url": prev_url,
|
||||
"next_url": next_url,
|
||||
"seed": seed,
|
||||
}
|
||||
return render(request, "gallery/detail.html", context)
|
||||
|
||||
|
||||
def gallery_download(request: HttpRequest, hash_id: str) -> HttpResponse:
|
||||
"""
|
||||
Эндпоинт для скачивания чистого SVG-файла картины.
|
||||
"""
|
||||
item = get_object_or_404(TbHypn0Item, s_hash_id=hash_id, is_public=True)
|
||||
|
||||
if not item.file_svg:
|
||||
raise Http404("SVG файл отсутствует")
|
||||
|
||||
try:
|
||||
with item.file_svg.open("r") as f:
|
||||
svg_content = f.read()
|
||||
if isinstance(svg_content, bytes):
|
||||
svg_content = svg_content.decode("utf-8")
|
||||
except Exception:
|
||||
raise Http404("SVG файл не может быть прочитан")
|
||||
|
||||
clean_svg = prepare_active_svg(svg_content)
|
||||
response = HttpResponse(clean_svg, content_type="image/svg+xml")
|
||||
filename = f"hypn0-{item.s_hash_id}.svg"
|
||||
response["Content-Disposition"] = f'attachment; filename="{filename}"'
|
||||
return response
|
||||
|
||||
|
||||
@require_POST
|
||||
def gallery_vote(request: HttpRequest, hash_id: str) -> HttpResponse:
|
||||
"""
|
||||
HTMX-эндпоинт для голосования (лайк) за картину в галерее.
|
||||
"""
|
||||
item = get_object_or_404(TbHypn0Item, s_hash_id=hash_id, is_public=True)
|
||||
visitor_uuid = request.COOKIES.get("hypn0_vid")
|
||||
|
||||
if not visitor_uuid:
|
||||
return render(
|
||||
request,
|
||||
"block/vote_button.html",
|
||||
{
|
||||
"item": item,
|
||||
"user_voted": False,
|
||||
"not_agreed": True,
|
||||
},
|
||||
)
|
||||
|
||||
voted = item.increment_likes(visitor_uuid)
|
||||
item.refresh_from_db(fields=["i_likes_count"])
|
||||
|
||||
return render(
|
||||
request,
|
||||
"block/vote_button.html",
|
||||
{
|
||||
"item": item,
|
||||
"user_voted": True,
|
||||
"voted_now": voted,
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user