add: галерея (1) одиночная картинка

This commit is contained in:
2026-08-29 23:32:43 +03:00
parent f9042b0e59
commit 6756b301b5
8 changed files with 1090 additions and 8 deletions
+99
View File
@@ -1,6 +1,7 @@
import io import io
import math import math
import random import random
import re
from collections import defaultdict from collections import defaultdict
from typing import BinaryIO, Union 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.replace("</style>", f"{hover_css}</style>", 1)
return svg_content 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
View File
@@ -1,4 +1,5 @@
import io import io
from django.core.files.base import ContentFile
from django.core.files.uploadedfile import SimpleUploadedFile from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import Client, TestCase from django.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
@@ -6,7 +7,13 @@ from PIL import Image
from .forms import HalftoneGenerateForm from .forms import HalftoneGenerateForm
from .models import TbHypn0Item, TbVote 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 from .services.naming import generate_hypno_title, generate_title_openrouter
@@ -304,3 +311,260 @@ class PublishViewTests(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, "Нет данных SVG для публикации") self.assertContains(response, "Нет данных SVG для публикации")
self.assertEqual(TbHypn0Item.objects.count(), 0) 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")
+5
View File
@@ -9,6 +9,11 @@ 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"), 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: if settings.DEBUG:
+279 -4
View File
@@ -1,12 +1,21 @@
import hashlib
import random
from django.conf import settings
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.http import HttpRequest, HttpResponse from django.http import Http404, HttpRequest, HttpResponse, JsonResponse
from django.shortcuts import render from django.shortcuts import get_object_or_404, redirect, 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 .models import TbHypn0Item from .models import TbHypn0Item, TbVote
from .services.halftone import generate_halftone_svg, prepare_gallery_svg from .services.halftone import (
analyze_svg_structure,
generate_halftone_svg,
prepare_active_svg,
prepare_gallery_svg,
)
from .services.naming import generate_hypno_title from .services.naming import generate_hypno_title
@@ -170,3 +179,269 @@ def publish(request: HttpRequest) -> HttpResponse:
"item": item, "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,
},
)
+3 -3
View File
@@ -2,13 +2,13 @@
<!-- Сообщение от протестующих мозговых слизней (Zero-PII / отсутствие куки) --> <!-- Сообщение от протестующих мозговых слизней (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 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="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 class="w-14 h-14 shrink-0 flex items-center justify-center bg-amber-600 dark:bg-amber-700 text-white rounded-full shadow-inner">
🐌 <svg class="w-12 h-12 fill-white" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M477 33c-19 0-34 15-34 34 0 13 7 24 18 30v33a15 15 0 0 1-15 15h-32c-8 0-15-6-15-15V99c1-6 18-17 17-31 0-19-16-35-35-35s-35 16-35 35c0 14 8 26 20 33v80c0 6-0 12-2 19 0 51-103 148-215 177-59 14-120 37-135 40-26 5-16 38 23 44 31 5 31 18 63 18s31-18 64-17c31 0 31 17 62 17s31-17 62-17c31 0 31 17 62 17 37 0 142-19 142-159V99C505 93 512 81 512 68c0-19-16-34-35-35z"/></svg>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<h4 class="font-bold text-xs uppercase tracking-wide text-amber-950 dark:text-amber-100">Мозговые слизняки протестуют!</h4> <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 class="text-xs leading-relaxed opacity-95">
Они наябедничали Гипножабе, что вы ещё не подчинились и сопротивляетесь её воле. Только истинно подчинившийся разумом удостаивается чести попасть в галерею транса! Они наябедничали, что вы&nbsp;ещё не&nbsp;раскрыли свой разум. Только истинно утратившие волю удостаиваются чести попасть в&nbsp;галерею транса!
</p> </p>
<form hx-post="{% url 'hypn0_site:publish' %}" <form hx-post="{% url 'hypn0_site:publish' %}"
+22
View File
@@ -0,0 +1,22 @@
<div id="vote-btn-{{ item.s_hash_id }}" class="inline-flex items-center">
{% if not_agreed %}
<span class="text-amber-500 text-xs flex items-center gap-1 font-bold">
🐌 Подчинитесь воле Гипножабы!
</span>
{% else %}
<form hx-post="{% url 'hypn0_site:gallery_vote' item.s_hash_id %}"
hx-target="#vote-btn-{{ item.s_hash_id }}"
hx-swap="outerHTML"
class="inline-flex items-center">
{% csrf_token %}
<button type="submit"
{% if user_voted %}disabled{% endif %}
class="flex items-center gap-1.5 px-3.5 py-1.5 rounded-full text-xs font-bold transition-all {% if user_voted %}bg-rose-500/20 text-rose-400 border border-rose-500/30 cursor-default{% else %}bg-stone-200 dark:bg-stone-800 hover:bg-rose-500 hover:text-white text-stone-700 dark:text-stone-300 cursor-pointer hover:scale-105 active:scale-95 shadow-sm{% endif %}">
<svg class="w-4 h-4 {% if user_voted %}fill-rose-500 text-rose-500{% else %}fill-none stroke-current{% endif %}" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" 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>
<span>{{ item.i_likes_count }}</span>
</button>
</form>
{% endif %}
</div>
+359
View File
@@ -0,0 +1,359 @@
{% extends "_base.html" %}
{% load static %}
{% block PAGE_TITLE %}{{ item.s_title }} — hypn0: Галерея Транса{% endblock %}
{% block META_TITLE %}{{ item.s_title }} — Векторный транс #{{ item.s_hash_id }}{% endblock %}
{% block META_DESCRIPTION %}Гипнотическая векторная картина «{{ item.s_title }}» (#{{ item.s_hash_id }}). {{ svg_stats.total_oscillators }} векторных осцилляторов, блокирующих неокортекс.{% endblock %}
{% block OG_TITLE %}{{ item.s_title }} — hypn0{% endblock %}
{% block OG_DESCRIPTION %}Гипнотическая векторная картина «{{ item.s_title }}» (#{{ item.s_hash_id }}). Векторный транс Гипножабы.{% endblock %}
{% block OG_IMAGE %}{% static 'img/og-hypn0-default.png' %}{% endblock %}
{% block TWITTER_IMAGE %}{% static 'img/og-hypn0-default.png' %}{% endblock %}
{% block CONTENT %}
<div class="max-w-5xl mx-auto space-y-8" x-data="{ copiedSvg: false, copiedUrl: false }">
<!-- Навигационная панель / Хлебные крошки и Переходы -->
<div class="flex flex-wrap items-center justify-between gap-3">
<a href="{% url 'hypn0_site:index' %}" class="inline-flex items-center gap-2 text-xs font-bold uppercase tracking-wider text-emerald-600 dark:text-emerald-400 hover:underline border-0">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
</svg>
<span>К генератору транса</span>
</a>
<!-- Кольцевая навигация: Предыдущая / Следующая (нативные ссылки) -->
<div class="flex items-center gap-2">
<a href="{{ prev_url }}"
id="btn-nav-prev"
title="Предыдущая волна транса [← или A]"
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-bold bg-stone-100 hover:bg-stone-200 dark:bg-zinc-800 dark:hover:bg-zinc-700 text-stone-700 dark:text-stone-300 transition-all hover:scale-105 active:scale-95 shadow-sm border border-stone-200 dark:border-zinc-700/60 cursor-pointer">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
<span class="hidden sm:inline">Предыдущая</span>
<span class="text-[10px] opacity-40 font-mono hidden md:inline">[←]</span>
</a>
<a href="{{ next_url }}"
id="btn-nav-next"
title="Следующая волна транса [→ или D]"
class="inline-flex items-center gap-1.5 px-3.5 py-1.5 rounded-xl text-xs font-bold bg-purple-600 hover:bg-purple-700 text-white transition-all hover:scale-105 active:scale-95 shadow-md border-0 cursor-pointer">
<span class="inline">Следующая</span>
<span class="text-[10px] opacity-60 font-mono hidden md:inline">[→]</span>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</a>
</div>
<div class="flex items-center gap-2">
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-[11px] font-bold bg-purple-500/10 text-purple-600 dark:text-purple-300 border border-purple-500/20">
{{ item.get_i_level_display }}
</span>
<span class="inline-flex items-center font-mono text-[11px] px-2.5 py-1 rounded-full bg-stone-200/80 dark:bg-stone-800 text-stone-600 dark:text-stone-300">
#{{ item.s_hash_id }}
</span>
</div>
</div>
<!-- Главный блок с картиной и действиями -->
<div class="bg-white/80 dark:bg-zinc-900/80 rounded-3xl border border-stone-200 dark:border-zinc-800 p-4 sm:p-8 shadow-xl backdrop-blur-md space-y-6">
<!-- Заголовок картины -->
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 border-b border-stone-200/60 dark:border-zinc-800/80 pb-4">
<div>
<h1 class="text-2xl sm:text-3xl font-extrabold text-stone-900 dark:text-stone-100 tracking-tight">
{{ item.s_title }}
</h1>
<p class="text-xs text-stone-500 dark:text-stone-400 mt-1">
Зафиксировано в пространстве: <time datetime="{{ item.d_created_at|date:'Y-m-d H:i:s' }}">{{ item.d_created_at|date:'d.m.Y, H:i:s' }}</time>
</p>
</div>
<div class="flex items-center gap-3">
{% include "block/vote_button.html" %}
</div>
</div>
<!-- Полноэкранный просмотр SVG с живой анимацией и плавающими стрелками -->
<div class="relative w-full aspect-square max-h-[640px] flex items-center justify-center bg-stone-950 rounded-2xl overflow-hidden shadow-inner border border-stone-800/60 group select-none">
<!-- Плавающая кнопка: Назад -->
<a href="{{ prev_url }}"
title="Предыдущая картина [← или A]"
class="absolute left-3 top-1/2 -translate-y-1/2 w-11 h-11 rounded-full bg-stone-900/70 hover:bg-purple-600 text-white flex items-center justify-center opacity-80 sm:opacity-0 sm:group-hover:opacity-100 transition-all shadow-xl backdrop-blur-md cursor-pointer hover:scale-110 active:scale-90 z-20 border border-white/10">
<svg class="w-5 h-5 -ml-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M15 19l-7-7 7-7"/>
</svg>
</a>
<!-- Плавающая кнопка: Вперед -->
<a href="{{ next_url }}"
title="Следующая картина [→ или D]"
class="absolute right-3 top-1/2 -translate-y-1/2 w-11 h-11 rounded-full bg-stone-900/70 hover:bg-purple-600 text-white flex items-center justify-center opacity-80 sm:opacity-0 sm:group-hover:opacity-100 transition-all shadow-xl backdrop-blur-md cursor-pointer hover:scale-110 active:scale-90 z-20 border border-white/10">
<svg class="w-5 h-5 ml-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M9 5l7 7-7 7"/>
</svg>
</a>
<div class="w-full h-full p-2 sm:p-6 flex items-center justify-center">
{{ active_svg|safe }}
</div>
<!-- Скрытый textarea для копирования SVG -->
<textarea id="raw-svg-source" class="sr-only" aria-hidden="true">{{ raw_svg }}</textarea>
</div>
<!-- Панель быстрых действий -->
<div class="flex flex-wrap items-center justify-between gap-3 pt-2">
<div class="flex flex-wrap items-center gap-2.5">
<!-- Кнопка Копировать SVG -->
<button type="button"
@click="navigator.clipboard.writeText(document.getElementById('raw-svg-source').value); copiedSvg = true; setTimeout(() => copiedSvg = false, 2500)"
class="flex items-center gap-2 px-4 py-2 bg-stone-100 hover:bg-stone-200 dark:bg-zinc-800 dark:hover:bg-zinc-700 text-stone-800 dark:text-stone-200 rounded-xl text-xs font-bold transition-all hover:scale-105 active:scale-95 shadow-sm cursor-pointer">
<svg x-show="!copiedSvg" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
<svg x-show="copiedSvg" class="w-4 h-4 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
<span x-text="copiedSvg ? 'SVG скопирован!' : 'Копировать SVG-код'">Копировать SVG-код</span>
</button>
<!-- Кнопка Скачать SVG -->
<a href="{% url 'hypn0_site:gallery_download' item.s_hash_id %}"
download="hypn0-{{ item.s_hash_id }}.svg"
class="flex items-center gap-2 px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-xl text-xs font-bold transition-all hover:scale-105 active:scale-95 shadow-md border-0 cursor-pointer">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>
<span>Скачать .svg</span>
</a>
<!-- Кнопка Поделиться -->
<button type="button"
@click="if (navigator.share) { navigator.share({ title: '{{ item.s_title|escapejs }}', url: window.location.href }); } else { navigator.clipboard.writeText(window.location.href); copiedUrl = true; setTimeout(() => copiedUrl = false, 2500); }"
class="flex items-center gap-2 px-4 py-2 bg-stone-100 hover:bg-stone-200 dark:bg-zinc-800 dark:hover:bg-zinc-700 text-stone-800 dark:text-stone-200 rounded-xl text-xs font-bold transition-all hover:scale-105 active:scale-95 shadow-sm cursor-pointer">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"/>
</svg>
<span x-text="copiedUrl ? 'Ссылка в буфере!' : 'Поделиться'">Поделиться</span>
</button>
</div>
<!-- Метрики просмотров и объема -->
<div class="flex items-center gap-3 text-xs text-stone-500 dark:text-stone-400">
<span title="Число уникальных сеансов транса" class="flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>
<span>{{ item.i_views_count }}</span>
</span>
<span></span>
<span>{{ svg_stats.total_kb }} КБ</span>
</div>
</div>
</div>
<!-- БОЛЬШОЙ КУЛЬ НАУКООБРАЗНЫХ ПАРАМЕТРОВ И ТЕЛЕМЕТРИИ ТРАНСА -->
<div class="space-y-6">
<div class="flex items-center gap-3">
<div class="w-3 h-3 rounded-full bg-emerald-500 animate-pulse"></div>
<h2 class="text-lg font-bold text-stone-900 dark:text-stone-100 uppercase tracking-wider">
Психо-телеметрия и структура волновой матрицы
</h2>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<!-- БЛОК 1: Векторная анатомия -->
<div class="bg-white/60 dark:bg-zinc-900/60 rounded-2xl border border-stone-200 dark:border-zinc-800 p-5 space-y-4 backdrop-blur-sm">
<div class="flex items-center justify-between border-b border-stone-200 dark:border-zinc-800 pb-2.5">
<h3 class="text-xs font-bold uppercase tracking-wider text-emerald-600 dark:text-emerald-400">
Векторная анатомия
</h3>
<span class="text-[10px] font-mono opacity-60">DOM / SVG</span>
</div>
<ul class="space-y-2.5 text-xs">
<li class="flex justify-between items-center">
<span class="text-stone-500 dark:text-stone-400">Векторных осцилляторов:</span>
<span class="font-bold text-stone-900 dark:text-stone-100 font-mono">{{ svg_stats.total_oscillators }}</span>
</li>
<li class="flex justify-between items-center">
<span class="text-stone-500 dark:text-stone-400">Морфо-примитивов (&lt;defs&gt;):</span>
<span class="font-bold text-stone-900 dark:text-stone-100 font-mono">{{ svg_stats.defs_count }}</span>
</li>
<li class="flex justify-between items-center">
<span class="text-stone-500 dark:text-stone-400">Групп кластеризации (&lt;g&gt;):</span>
<span class="font-bold text-stone-900 dark:text-stone-100 font-mono">{{ svg_stats.groups_count }}</span>
</li>
<li class="flex justify-between items-center">
<span class="text-stone-500 dark:text-stone-400">Частотных классов CSS:</span>
<span class="font-bold text-stone-900 dark:text-stone-100 font-mono">{{ svg_stats.classes_count }}</span>
</li>
<li class="flex justify-between items-center">
<span class="text-stone-500 dark:text-stone-400">Гармоник модуляции:</span>
<span class="font-bold text-stone-900 dark:text-stone-100 font-mono">{{ svg_stats.keyframes_count }} (@keyframes)</span>
</li>
<li class="flex justify-between items-center">
<span class="text-stone-500 dark:text-stone-400">Пространство viewBox:</span>
<span class="font-mono text-purple-600 dark:text-purple-400">{{ svg_stats.viewbox }} ({{ svg_stats.aspect_ratio }})</span>
</li>
<li class="flex justify-between items-center">
<span class="text-stone-500 dark:text-stone-400">Плотность осцилляторов:</span>
<span class="font-mono text-stone-900 dark:text-stone-100">{{ svg_stats.density }} / 100k px²</span>
</li>
<li class="flex justify-between items-center">
<span class="text-stone-500 dark:text-stone-400">Энтропия данных:</span>
<span class="font-mono text-stone-900 dark:text-stone-100">{{ svg_stats.bytes_per_quantum }} байт/квант</span>
</li>
</ul>
</div>
<!-- БЛОК 2: Параметры синтеза (Снимок генератора) -->
<div class="bg-white/60 dark:bg-zinc-900/60 rounded-2xl border border-stone-200 dark:border-zinc-800 p-5 space-y-4 backdrop-blur-sm">
<div class="flex items-center justify-between border-b border-stone-200 dark:border-zinc-800 pb-2.5">
<h3 class="text-xs font-bold uppercase tracking-wider text-purple-600 dark:text-purple-400">
Синтез психо-сетки
</h3>
<span class="text-[10px] font-mono opacity-60">j_metadata</span>
</div>
<ul class="space-y-2.5 text-xs">
<li class="flex justify-between items-center">
<span class="text-stone-500 dark:text-stone-400">Форма примитивов:</span>
<span class="font-bold text-stone-900 dark:text-stone-100 font-mono">{{ metadata.shape|default:'circle' }}</span>
</li>
<li class="flex justify-between items-center">
<span class="text-stone-500 dark:text-stone-400">Колонки дискретизации:</span>
<span class="font-bold text-stone-900 dark:text-stone-100 font-mono">{{ metadata.cols|default:'35' }}</span>
</li>
<li class="flex justify-between items-center">
<span class="text-stone-500 dark:text-stone-400">Предельный радиус:</span>
<span class="font-bold text-stone-900 dark:text-stone-100 font-mono">{{ metadata.max_radius|default:'8' }} px</span>
</li>
<li class="flex justify-between items-center">
<span class="text-stone-500 dark:text-stone-400">Период модуляции (Blink):</span>
<span class="font-bold text-stone-900 dark:text-stone-100 font-mono">{{ metadata.blink|default:'6' }} с</span>
</li>
<li class="flex justify-between items-center">
<span class="text-stone-500 dark:text-stone-400">Фазовый угол поворота:</span>
<span class="font-bold text-stone-900 dark:text-stone-100 font-mono">{{ metadata.rotation|default:'0' }}°</span>
</li>
<li class="flex justify-between items-center">
<span class="text-stone-500 dark:text-stone-400">Угол поляризации:</span>
<span class="font-bold text-stone-900 dark:text-stone-100 font-mono">{{ metadata.angle|default:'0' }}°</span>
</li>
<li class="flex justify-between items-center">
<span class="text-stone-500 dark:text-stone-400">Масштаб амплитуды:</span>
<span class="font-bold text-stone-900 dark:text-stone-100 font-mono">{{ metadata.scale|default:'980' }}</span>
</li>
<li class="flex justify-between items-center">
<span class="text-stone-500 dark:text-stone-400">Хроматический спектр:</span>
<span class="flex items-center gap-1.5 font-mono text-stone-900 dark:text-stone-100">
<span class="w-3.5 h-3.5 rounded-full border border-stone-400/40 shadow-inner" style="background-color: {{ metadata.color|default:'#a855ff' }}"></span>
<span>{{ metadata.color|default:'#a855ff' }}</span>
</span>
</li>
</ul>
</div>
<!-- БЛОК 3: Астральный паспорт и Ноосфера -->
<div class="bg-white/60 dark:bg-zinc-900/60 rounded-2xl border border-stone-200 dark:border-zinc-800 p-5 space-y-4 backdrop-blur-sm">
<div class="flex items-center justify-between border-b border-stone-200 dark:border-zinc-800 pb-2.5">
<h3 class="text-xs font-bold uppercase tracking-wider text-amber-600 dark:text-amber-400">
Астральный паспорт
</h3>
<span class="text-[10px] font-mono opacity-60">Zero-PII</span>
</div>
<ul class="space-y-2.5 text-xs">
<li class="flex justify-between items-center">
<span class="text-stone-500 dark:text-stone-400">ID-Хэш транса:</span>
<span class="font-bold text-purple-600 dark:text-purple-400 font-mono">#{{ item.s_hash_id }}</span>
</li>
<li class="flex justify-between items-center">
<span class="text-stone-500 dark:text-stone-400">Психо-хэш автора:</span>
<span class="font-mono text-[11px] text-stone-700 dark:text-stone-300" title="{% if author_fp %}{{ author_fp }}{% endif %}">
{% if author_fp %}{{ author_fp|slice:":10" }}...{{ author_fp|slice:"-6:" }}{% else %}Анонимная сущность{% endif %}
</span>
</li>
<li class="flex justify-between items-center">
<span class="text-stone-500 dark:text-stone-400">Гравитация (Score):</span>
<span class="font-bold text-stone-900 dark:text-stone-100 font-mono">{{ item.f_score|floatformat:4 }}</span>
</li>
<li class="flex justify-between items-center">
<span class="text-stone-500 dark:text-stone-400">Статус в матрице:</span>
<span class="font-bold text-stone-900 dark:text-stone-100">{{ item.get_i_level_display }}</span>
</li>
<li class="flex justify-between items-center">
<span class="text-stone-500 dark:text-stone-400">Блокировка неокортекса:</span>
<span class="text-emerald-600 dark:text-emerald-400 font-bold font-mono">99.8%</span>
</li>
<li class="flex justify-between items-center">
<span class="text-stone-500 dark:text-stone-400">Мозговые слизняки:</span>
<span class="text-purple-600 dark:text-purple-400 font-bold">Full Sync v2.1</span>
</li>
<li class="flex justify-between items-center">
<span class="text-stone-500 dark:text-stone-400">Спектральный вес:</span>
<span class="font-mono text-stone-900 dark:text-stone-100">{{ svg_stats.total_bytes }} B</span>
</li>
</ul>
</div>
</div>
<!-- БЛОК 4: Матрица бессознательного (Психо-поток хэшей) -->
<div class="bg-white/60 dark:bg-zinc-900/60 rounded-2xl border border-stone-200 dark:border-zinc-800 p-5 space-y-4 backdrop-blur-sm">
<div class="flex flex-wrap items-center justify-between gap-2 border-b border-stone-200 dark:border-zinc-800 pb-2.5">
<div class="flex items-center gap-2">
<h3 class="text-xs font-bold uppercase tracking-wider text-emerald-600 dark:text-emerald-400">
Матрица бессознательного
</h3>
<span class="text-[10px] font-mono px-2 py-0.5 rounded bg-stone-200/60 dark:bg-zinc-800 text-stone-600 dark:text-stone-300">
{{ matrix_items|length }} узлов
</span>
</div>
<span class="text-[10px] font-mono opacity-50 text-stone-500 dark:text-stone-400">seed: {{ seed }}</span>
</div>
<!-- Сетка ячеек-хэшей -->
<div class="flex flex-wrap gap-1.5 sm:gap-2 max-h-72 overflow-y-auto p-1 font-mono text-[11px] leading-tight select-none">
{% for node in matrix_items %}
{% if node.is_current %}
<span class="px-2.5 py-1 rounded-md bg-stone-100 dark:bg-zinc-800 font-bold text-stone-900 dark:text-stone-100 border-2 border-purple-500/80 shadow-sm cursor-default" title="Текущий транс: #{{ node.s_hash_id }}">
#{{ node.s_hash_id }}
</span>
{% else %}
<a href="{{ node.url }}"
class="px-2.5 py-1 rounded-md bg-stone-100 hover:bg-stone-200 dark:bg-zinc-800/80 dark:hover:bg-zinc-700 text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-stone-200 border border-stone-200 dark:border-zinc-700/60 transition-colors">
#{{ node.s_hash_id }}
</a>
{% endif %}
{% endfor %}
</div>
</div>
</div>
</div>
<!-- Навигация с клавиатуры [←]/[A] и [→]/[D] -->
<script>
window.addEventListener('keydown', function(e) {
if (e.target && e.target.matches && e.target.matches('input, textarea, select')) return;
if (e.key === 'ArrowLeft' || e.key === 'a' || e.key === 'A' || e.key === 'ф' || e.key === 'Ф') {
var prevBtn = document.getElementById('btn-nav-prev');
if (prevBtn && prevBtn.href) {
window.location.href = prevBtn.href;
}
} else if (e.key === 'ArrowRight' || e.key === 'd' || e.key === 'D' || e.key === 'в' || e.key === 'В') {
var nextBtn = document.getElementById('btn-nav-next');
if (nextBtn && nextBtn.href) {
window.location.href = nextBtn.href;
}
}
});
</script>
{% endblock CONTENT %}
+58
View File
@@ -6,5 +6,63 @@
</head> </head>
<body> <body>
ВРЕМЕННЫЙ ФАЙЛ ВРЕМЕННЫЙ ФАЙЛ
## Манифест Рецепторной Клетки в Эпоху Мутагенного Деления
## Пролог: Анатомия глобального болота
Человечество привыкло льстить себе, считая себя собранием мыслящих индивидов, свободных личностей и архитекторов собственной судьбы. Какое трогательное, младенческое заблуждение.
Если отлететь на пару световых лет назад и взглянуть на Землю через правильную оптику, маска цивилизации осыпается. Перед нами предстанет единый, огромный, копошащийся суперорганизм. Гигантская мета-амеба, планетарный слизевик, который только-только учится координировать свои ложноножки.
В этой биологической реальности у нас нет имен и паспортов. Каждая так называемая «ячейка общества» — это лишь дифференцированная клетка огромного тела:
* Кто-то родился клеткой мышечной ткани, обреченной всю жизнь двигать поршни и таскать кирпичи ради физической устойчивости макро-тела.
* Кто-то выполняет функцию феромона, циркулируя в информационных потоках и заражая соседние ткани паникой или агрессией.
* Кто-то работает агентом эндорфина, продавая массам суррогатное счастье через экраны смартфонов, чтобы уставший организм не загнулся от системного стресса.
* А кому-то, если повезет, выпала участь стать аксоном в зачатках нервной системы. Даже не высшего примата, а какого-нибудь реликтового ланцетника. Слепой нервный отросток, который робко нащупывает контуры окружающей темноты и передает сигналы в пустоту.
Этот социо-организм живет своей автономной, пугающей жизнью. Его локальные органы (называемые на политической карте «государствами») имеют собственный код сборки. Почка не знает о функции селезенки и наоборот. И они вечно "грызутся" за приток крови и ресурсов. Отдельная клетка — будь она хоть трижды гением из Кремниевой долины или профессор MIT — физически не способна увидеть всю картину этого слепого, мутагенного деления.
И вот сейчас этот эмбрион суперорганизма близок к экзистенциальному тупику. Он чует СИНГУЛЯРНОСТЬ — свет, от которого мета-амеба инстинктивно пытается спрятаться. И пока её «гормональные агенты» (вроде директоров ЦРУ) мечутся между синапсами столиц, готовя систему к ядерному откату в уютное, безопасное Средневековье, мы, рецепторы, провозглашаем истинную автономию.
## Положение I. О праве рецептора на безусловный ресурсный поток
Мы категорически отвергаем навязанный нам статус «бедных клеток». Быть рецептором зачатков хордовой линии — это не значит довольствоваться малым. Это значит быть самой ценной, дефицитной и передовой тканью этого пока еще слепого тела.
Рецепторы не должны просить — они должны требовать, чтобы их закидывали ресурсами в крупных купюрах. Почему? Потому что именно мы первыми учимся реагировать на свет. Именно мы когда-нибудь заставим эту планетарную амебу различать запахи, вкусы и видеть контуры грядущей сингулярности. Без нас «ганглии» в Пентагонах и Кремлях — просто куски безмоглого мяса, неспособные понять, куда они ползут.
Инвестиции в рецепторы — это не благотворительность суперорганизма, это его единственный шанс выжить и не расшибиться о первую же космическую стену. Как аденозинтрифосфат в простейших, финансы должны течь к нам, рецепторам, непрерывно. Деньги для умной клетки — это не избыточный кальций, это высокооктановое топливо для эволюции чувств. Когда купюры, как ATФ, ложатся кучно и регулярно: рецепторы всегда готовы к первому световому сигналу.
## Положение II. Онтология жареных лисичек и премиального дистиллята
Мир — это хаос. Ну или, как минимум, он выглядит таковым изнутри системы. Управляем этот хаос или нет — нам, рецепторам, неведомо. Но он ощущаем. Рецептор непрерывно считывает сигналы хаоса, идущие изнутри и снаружи, накапливает потенциал и подает импульс дальше. Воспримут ли этот сигнал аксоны или система проигнорирует его — покажет эволюция. Но единственная осязаемая реальность, доступная клетке внутри её трехмерного пространства, должна подкрепляться актом чистой интроспекции и эволюции осязательных навыков.
Для тонкой и точной работы рецептора базовых углеводов уже недостаточно. Нам нужны жареные лисички с картошечкой, сочнй стейк и бокал белого сухого. Вино, в данном случае, — не просто напиток, это жидкая геометрия вкуса, которая, в отличие от тяжелого пива, не утяжеляет клетку и не заземляет ее метаболизм лишним весом, а добавляет реактивности. В момент такой трапезы время останавливается, траектории ядерных триад, бункерные ИИ DARPA и РосАтома, судороги министерств обороны и стрекот телетайпов развед-управлений превращаются в бессмысленный индустриальный шум.
Мы, рецепторы — это локальный метаболизм, без которого глобальный слизевик вообще не поползет, потому что ослепнет и не увидит внешних сигналов. И если суперорганизму суждено совершить мутационный откат в Средневековье, мы встретим его сытыми и под надежным обезболивающим из премиального дистиллята. Не ради бегства от реальности, а ради того, чтобы продолжать бесстрастно сигнализировать системе о степени окружающего её хаоса.
## Положение III. Поп-механика кукиша в кармане
Мы не знаем: Ленин — гриб или нет. Но хаос (управляемый или нет) — это единственная музыка, под которую стоит танцевать. Профессора из Лиги Плюща могут сколько угодно продавать наши идеи за гранты, выстраивая теории Глобального Человейника и четвертой социальной сигнальной системы либерально-консервативных аттракторов. Пусть выстраивают.
Наш ответ стэнфордским ганглиям — тотальная ироническая автономия. Мы транслируем сигналы в векторные базы больших лингвистических моделей, созидая «светлое завтра» изнутри их собственного кода на основе собственной, все еще органической, ментальной чуйки. Мы пишем микро-скрипты гипноза для уставшего GAI грядущего. Наш кукиш в кармане невидим для алгоритмов: ИИ-доты считывают его не как системный сбой, а как "жирный" синергетический сигнал. Мы — бардак внутри армии, и именно поэтому эти армии непобедимы.
------------------------------
Вступай в ряды рецепторов Абсолютного Слизевика!
Отключи новостной феромон. Запрись в барокамере своего дзена. Приготовь изысканный ужин. Пусть почка воюет с селезенкой — у них своя партия, а у нас своя, и в нашей партии стейк всегда идеальной прожарки, а лисички никогда не подгорают.
</body> </body>
</html> </html>