import hashlib import random from django.conf import settings from django.core.files.base import ContentFile 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, TbVote from .services.halftone import ( analyze_svg_structure, generate_halftone_svg, prepare_active_svg, prepare_gallery_svg, ) from .services.naming import generate_hypno_title @ensure_csrf_cookie def index(request: HttpRequest | None) -> HttpResponse: return render(request, "index.html", {}) def tmp(request: HttpRequest | None) -> HttpResponse: return render(request, "tmp.html", {}) @require_POST def generate(request: HttpRequest) -> HttpResponse: """ HTMX-эндпоинт для генерации гипнотического полутонового SVG на лету. Не сохраняет данные в БД (Zero-Disk / Zero-PII). """ form = HalftoneGenerateForm(request.POST, request.FILES) if not form.is_valid(): error_msg = next(iter(form.errors.values()))[0] if form.errors else "Ошибка параметров генерации" return render(request, "block/preview.html", {"error": error_msg}) image_file = form.cleaned_data["image"] shape = form.cleaned_data["shape"] cols = form.cleaned_data["cols"] max_radius = form.cleaned_data["max_radius"] blink = form.cleaned_data["blink"] rotation = form.cleaned_data["rotation"] scale = form.cleaned_data["scale"] angle = form.cleaned_data["angle"] # Извлечение основного цвета (поддержка монотонной схемы) colors = request.POST.getlist("colors") primary_color = colors[0] if colors and colors[0] else "#a855ff" try: svg_content = generate_halftone_svg( image=image_file, cols=cols, max_radius=max_radius, shape=shape, color=primary_color, blink=blink, rotation=rotation, scale=scale, angle=angle, ) except Exception as e: return render( request, "block/preview.html", {"error": f"Сбой в матрице гипноза: {str(e)}"}, ) return render( request, "block/preview.html", { "svg_content": svg_content, "form_data": form.cleaned_data, "primary_color": primary_color, }, ) @require_POST def publish(request: HttpRequest) -> HttpResponse: """ HTMX-эндпоинт для сохранения текущей генерации как кандидата в галерею транса. Требует согласия на отслеживание (Zero-PII cookie hypn0_vid). """ svg_content = request.POST.get("svg_content", "").strip() if not svg_content: return render( request, "block/publish_status.html", {"error": "Нет данных SVG для публикации. Попробуйте сгенерировать заново."}, ) visitor_uuid = request.COOKIES.get("hypn0_vid") if not visitor_uuid: # Мозговые слизняки протестуют: пользователь не дал согласия и не подчинился Гипножабе return render( request, "block/publish_status.html", { "not_agreed": True, "svg_content": svg_content, "shape": request.POST.get("shape", "circle"), "cols": request.POST.get("cols", "35"), "max_radius": request.POST.get("max_radius", "8"), "blink": request.POST.get("blink", "6"), "rotation": request.POST.get("rotation", "0"), "scale": request.POST.get("scale", "980"), "angle": request.POST.get("angle", "0"), "color": request.POST.get("color", "#a855ff"), }, ) # 1. Генерируем гипнотическое название title = generate_hypno_title() # 2. Подготавливаем SVG для галереи (пауза по умолчанию + hover) gallery_svg = prepare_gallery_svg(svg_content) svg_bytes = gallery_svg.encode("utf-8") # 3. Собираем параметры генерации в метаданные metadata = { "shape": request.POST.get("shape", "circle"), "cols": int(request.POST.get("cols", 35)) if request.POST.get("cols", "").isdigit() else 35, "max_radius": int(request.POST.get("max_radius", 8)) if request.POST.get("max_radius", "").isdigit() else 8, "blink": int(request.POST.get("blink", 6)) if request.POST.get("blink", "").isdigit() else 6, "rotation": int(request.POST.get("rotation", 0)) if request.POST.get("rotation", "").lstrip("-").isdigit() else 0, "scale": int(request.POST.get("scale", 980)) if request.POST.get("scale", "").isdigit() else 980, "angle": int(request.POST.get("angle", 0)) if request.POST.get("angle", "").lstrip("-").isdigit() else 0, "color": request.POST.get("color", "#a855ff"), } try: svg_file = ContentFile(svg_bytes, name="hypn0.svg") item = TbHypn0Item( s_title=title, file_svg=svg_file, i_file_size=len(svg_bytes), j_metadata=metadata, i_level=TbHypn0Item.Level.CANDIDATE, is_public=True, ) item.save(visitor_uuid_or_fp=visitor_uuid) except PermissionError: return render( request, "block/publish_status.html", { "not_agreed": True, "svg_content": svg_content, "shape": request.POST.get("shape", "circle"), "cols": request.POST.get("cols", "35"), "max_radius": request.POST.get("max_radius", "8"), "blink": request.POST.get("blink", "6"), "rotation": request.POST.get("rotation", "0"), "scale": request.POST.get("scale", "980"), "angle": request.POST.get("angle", "0"), "color": request.POST.get("color", "#a855ff"), }, ) except Exception as e: return render( request, "block/publish_status.html", {"error": f"Сбой фиксации в трансе: {str(e)}"}, ) return render( request, "block/publish_status.html", { "success": True, "item": item, }, ) 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: "_", например "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, }, )