From 05bb583d8a60f527972e6d014030e6d35ac9c368 Mon Sep 17 00:00:00 2001 From: erjemin Date: Sun, 23 Aug 2026 22:14:49 +0300 Subject: [PATCH] =?UTF-8?q?add:=20core=20(01)=20=D0=B4=D1=80=D0=B0=D1=84?= =?UTF-8?q?=D1=82...=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=D0=B5=D1=82!!?= =?UTF-8?q?=20=D0=93=D0=B5=D0=BD=D0=B5=D1=80=D0=B0=D1=86=D0=B8=D1=8F=20SVG?= =?UTF-8?q?=20=D0=BF=D1=80=D0=BE=D1=85=D0=BE=D0=B4=D0=B8=D1=82!!!?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hypn0/hypn0_site/forms.py | 97 +++++++++++++++ hypn0/hypn0_site/tests.py | 192 ++++++++++++++++++++++++++++- hypn0/hypn0_site/urls.py | 6 +- hypn0/hypn0_site/views.py | 66 +++++++++- hypn0/templates/_base.html | 14 ++- hypn0/templates/block/preview.html | 26 ++++ hypn0/templates/index.html | 37 +++++- 7 files changed, 425 insertions(+), 13 deletions(-) create mode 100644 hypn0/hypn0_site/forms.py create mode 100644 hypn0/templates/block/preview.html diff --git a/hypn0/hypn0_site/forms.py b/hypn0/hypn0_site/forms.py new file mode 100644 index 0000000..3d89817 --- /dev/null +++ b/hypn0/hypn0_site/forms.py @@ -0,0 +1,97 @@ +from django import forms + + +class HalftoneGenerateForm(forms.Form): + """ + Форма валидации входных параметров для генерации гипнотического SVG. + """ + SHAPE_CHOICES = [ + ("circle", "Круг"), + ("ring", "Кольцо"), + ("triangle", "Треугольник"), + ("square", "Квадрат"), + ("diamond", "Ромб"), + ("hexagon", "Шестиугольник"), + ("star", "Звезда"), + ("cross", "Крестик"), + ("line", "Линия"), + ("wave", "Волна"), + ("heart", "Сердечко"), + ] + + image = forms.ImageField( + required=True, + error_messages={ + "required": "Покорми Гипножабу изображением!", + "invalid_image": "Гипножаба не может переварить этот файл. Нужна картинка (PNG, JPG, WEBP).", + }, + ) + shape = forms.ChoiceField( + choices=SHAPE_CHOICES, + initial="circle", + required=False, + ) + cols = forms.IntegerField( + min_value=20, + max_value=200, + initial=80, + required=False, + ) + max_radius = forms.IntegerField( + min_value=3, + max_value=20, + initial=8, + required=False, + ) + blink = forms.IntegerField( + min_value=0, + max_value=10, + initial=6, + required=False, + ) + rotation = forms.IntegerField( + min_value=-15, + max_value=15, + initial=0, + required=False, + ) + scale = forms.IntegerField( + min_value=800, + max_value=1040, + initial=980, + required=False, + ) + angle = forms.IntegerField( + min_value=-44, + max_value=45, + initial=0, + required=False, + ) + + def clean_shape(self): + shape = self.cleaned_data.get("shape") + if not shape: + return "circle" + return shape + + def clean_cols(self): + return self.cleaned_data.get("cols") or 80 + + def clean_max_radius(self): + return self.cleaned_data.get("max_radius") or 8 + + def clean_blink(self): + val = self.cleaned_data.get("blink") + return 6 if val is None else val + + def clean_rotation(self): + val = self.cleaned_data.get("rotation") + return 0 if val is None else val + + def clean_scale(self): + val = self.cleaned_data.get("scale") + return 980 if val is None else val + + def clean_angle(self): + val = self.cleaned_data.get("angle") + return 0 if val is None else val diff --git a/hypn0/hypn0_site/tests.py b/hypn0/hypn0_site/tests.py index 7ce503c..7f59b4c 100644 --- a/hypn0/hypn0_site/tests.py +++ b/hypn0/hypn0_site/tests.py @@ -1,3 +1,191 @@ -from django.test import TestCase +import io +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import Client, TestCase +from django.urls import reverse +from PIL import Image -# Create your tests here. +from .forms import HalftoneGenerateForm +from .services.halftone import encode_to_base36, generate_halftone_svg + + +class HalftoneServiceTests(TestCase): + """Тестирование сервиса генерации SVG (services/halftone.py).""" + + def setUp(self): + # Создаем простое тестовое изображение 50x50 с градиентом + self.img = Image.new("RGB", (50, 50), color="white") + for x in range(25): + for y in range(50): + self.img.putpixel((x, y), (0, 0, 0)) + + def test_encode_to_base36(self): + self.assertEqual(encode_to_base36(0), "a") + self.assertEqual(encode_to_base36(1), "b") + with self.assertRaises(ValueError): + encode_to_base36(-1) + + def test_generate_default_svg(self): + svg = generate_halftone_svg(self.img, cols=30, max_radius=6) + self.assertIn("", svg) + self.assertIn("", svg) + self.assertIn('', svg) + + def test_generate_shapes(self): + shapes = ["circle", "ring", "square", "diamond", "triangle", "hexagon", "star", "cross", "line", "wave", "heart"] + for shape in shapes: + svg = generate_halftone_svg(self.img, cols=20, max_radius=5, shape=shape) + self.assertIn("", svg) + self.assertIn("", svg) + + def test_generate_static_blink_zero(self): + svg = generate_halftone_svg(self.img, cols=20, blink=0) + self.assertIn("animation:none", svg) + self.assertNotIn("@keyframes noise", svg) + + def test_generate_animated_params(self): + svg = generate_halftone_svg( + self.img, + cols=20, + blink=8, + rotation=12, + scale=920, + angle=15, + color="#ff5500", + ) + self.assertIn("@keyframes noise", svg) + self.assertIn("rotate(12deg)", svg) + self.assertIn("scale(0.92)", svg) + self.assertIn("#ff5500", svg) + + def test_generate_from_bytes(self): + buf = io.BytesIO() + self.img.save(buf, format="PNG") + svg = generate_halftone_svg(buf.getvalue(), cols=20) + self.assertIn(" HttpResponse: - return render(request, 'index.html', {}) + return render(request, "index.html", {}) def tmp(request: HttpRequest | None) -> HttpResponse: - return render(request, 'tmp.html', {}) \ No newline at end of file + 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, + }, + ) diff --git a/hypn0/templates/_base.html b/hypn0/templates/_base.html index 464937e..2a69700 100644 --- a/hypn0/templates/_base.html +++ b/hypn0/templates/_base.html @@ -110,9 +110,21 @@ val=>{if(val)document.documentElement.classList.add('dark');else document.docume {% endblock EXTRA_LD_JSON %} + {% block EXTRA_HEAD %}{% endblock EXTRA_HEAD %} -{% block ADD_CSS1 %}{% endblock %}{% block ADD_CSS2 %}{% endblock %}{% block ADD_CSS3 %}{% endblock %}{% block BODY %} +{% block ADD_CSS1 %}{% endblock %}{% block ADD_CSS2 %}{% endblock %}{% block ADD_CSS3 %}{% endblock %}{% block BODY %} {% include "block/header.html" %}
{% block CONTENT %}{% endblock CONTENT %} diff --git a/hypn0/templates/block/preview.html b/hypn0/templates/block/preview.html new file mode 100644 index 0000000..fef3f13 --- /dev/null +++ b/hypn0/templates/block/preview.html @@ -0,0 +1,26 @@ +{% if error %} +
+ + + +

+ {{ error }} +

+
+{% else %} +
+ {{ svg_content|safe }} +
+ + +
+ +
+{% endif %} diff --git a/hypn0/templates/index.html b/hypn0/templates/index.html index 023e69a..6dc1841 100644 --- a/hypn0/templates/index.html +++ b/hypn0/templates/index.html @@ -23,6 +23,7 @@ hasChanges: true, /* Флаг наличия правок в форме для активности кнопки */ hasImage: false, /* Флаг загрузки пользовательского файла */ timer: null, /* Ссылка на таймер удержания анимации размышления */ + copied: false, /* Флаг успешного копирования в буфер */ /* Метод запуска состояния размышления (8 секунд) */ startThinking() { @@ -32,6 +33,29 @@ this.isLoading = false; this.hasChanges = false; }, 8000); + }, + + copySvg() { + const svg = document.querySelector('#preview svg'); + if (!svg) return; + navigator.clipboard.writeText(svg.outerHTML).then(() => { + this.copied = true; + setTimeout(() => this.copied = false, 2000); + }); + }, + + downloadSvg() { + const svg = document.querySelector('#preview svg'); + if (!svg) return; + const blob = new Blob([svg.outerHTML], {type: 'image/svg+xml;charset=utf-8'}); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'hypn0-art.svg'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); } }"> @@ -45,7 +69,9 @@ @change="hasChanges = true" @input="hasChanges = true" @htmx:before-request="startThinking()" + @htmx:after-request="isLoading = false; hasChanges = false" class="relative bg-white dark:bg-zinc-900 p-6 rounded-2xl shadow-sm border border-stone-200 dark:border-zinc-800 overflow-hidden"> + {% csrf_token %}
Источник
@@ -410,13 +437,17 @@