Download renderer.py from FincH12/Automation_Threads_EU: direct link, hf CLI and curl.
- Browser
- Download file 11.4 kB
-
https://huggingface.co/spaces/FincH12/Automation_Threads_EU/resolve/main/renderer.py
- Command line
-
hf download hf://spaces/FincH12/Automation_Threads_EU/renderer.py
-
curl -L -o renderer.py https://huggingface.co/spaces/FincH12/Automation_Threads_EU/resolve/main/renderer.py
11.4 kB
| # -*- coding: utf-8 -*- | |
| import os | |
| import html | |
| import requests | |
| import base64 | |
| import subprocess | |
| import re | |
| import random | |
| import urllib.parse | |
| from config import NVIDIA_NIM_API_KEY, OUTPUT_DIR, BASE_DIR | |
| # Модели для Nvidia NIM (в порядке приоритета) | |
| NVIDIA_MODELS = [ | |
| ("black-forest-labs/flux.1-dev", 25), # Основная (детальная) | |
| ("black-forest-labs/flux.1-schnell", 4) # Быстрая (резервная) | |
| ] | |
| def download_background(prompt, save_path): | |
| """ | |
| Делает запрос к Nvidia NIM (Flux.1-dev). В случае ошибки или таймаута | |
| делает вторую попытку, а если снова неудача - переходит на Pollinations.ai. | |
| """ | |
| headers = { | |
| "Authorization": f"Bearer {NVIDIA_NIM_API_KEY}", | |
| "Content-Type": "application/json", | |
| "Accept": "application/json" | |
| } | |
| print(f"[Renderer] Запрос фонового рисунка у Nvidia NIM...") | |
| for model_name, steps in NVIDIA_MODELS: | |
| api_url = f"https://ai.api.nvidia.com/v1/genai/{model_name}" | |
| for attempt in range(1, 3): | |
| payload = { | |
| "prompt": prompt, | |
| "cfg_scale": 0 if "schnell" in model_name else 5, | |
| "steps": steps, | |
| "seed": random.randint(1, 1000000) | |
| } | |
| try: | |
| response = requests.post(api_url, headers=headers, json=payload, timeout=45) | |
| if response.status_code == 200: | |
| response_data = response.json() | |
| image_base64 = response_data['artifacts'][0]['base64'] | |
| with open(save_path, "wb") as f: | |
| f.write(base64.b64decode(image_base64)) | |
| print(f"[Renderer] Фоновое изображение сохранено (Nvidia NIM, модель: {model_name})") | |
| return True | |
| else: | |
| print(f"[Renderer Error] Ошибка {model_name} ({response.status_code}): {response.text}") | |
| except Exception as e: | |
| print(f"[Renderer Exception] Ошибка {model_name} (попытка {attempt}): {e}") | |
| print(f"[Renderer Warning] Модель {model_name} не ответила. Переключение на следующую...") | |
| print("[Renderer Error] Все модели Nvidia NIM упали!") | |
| return False | |
| def apply_typography_rules(text: str) -> str: | |
| """ | |
| Применяет правила русской/украинской типографики для предотвращения висячих строк: | |
| 1. Неразрывный пробел ( ) после предлогов и союзов (1-3 буквы). | |
| 2. Неразрывный пробел между числом и связанным символом/словом/единицей измерения. | |
| """ | |
| if not text: | |
| return "" | |
| # 1. Замена пробелов после коротких предлогов и союзов (1-3 символа) | |
| word_pattern = re.compile(r'\b([a-zA-Zа-яА-ЯёЁіІїЇєЄґҐ]{1,3})\s+') | |
| text = word_pattern.sub(r'\1 ', text) | |
| # 2. Неразрывные переносы для связанных сущностей: | |
| # Число + Символ (например: 50%, №1, $100) | |
| text = re.sub(r'(\d+)\s+([%‰$€£¥№])', r'\1 \2', text) | |
| text = re.sub(r'([$€£¥№])\s+(\d+)', r'\1 \2', text) | |
| # Число + Слово (например: 4 роки, 100 км, 2026 рік, 3 години) | |
| text = re.sub(r'(\d+)\s+([a-zA-Zа-яА-ЯёЁіІїЇєЄґҐ]+)', r'\1 \2', text) | |
| return text | |
| def highlight_keywords(text: str) -> str: | |
| """ | |
| Автоматически находит триггерные слова и цифры и выделяет их тегом <span class="highlight">...</span>. | |
| """ | |
| if not text: | |
| return "" | |
| # Защищаем существующие неразрывные пробелы | |
| text = text.replace(" ", "___NBSP___") | |
| # 1. Выделение чисел со связанными словами/символами (до 8 символов за числом) | |
| num_pattern = re.compile(r'(\b\d+[%$€№]?(?:\s+[a-zA-Zа-яА-ЯёЁіІїЇєЄґҐ]{1,8})?\b)') | |
| text = num_pattern.sub(r'<span class="highlight">\1</span>', text) | |
| # 2. Выделение триггерных слов динамики/результата | |
| keywords_pattern = re.compile( | |
| r'(\b(?:подвоїть|подвоїться|потроїть|зменшить|зменшиться|збільшить|збільшиться|впаде|зросте|рекорд|рекордний|рекордно|вперше|критичн\w*|максимальн\w*|катастроф\w*|прорив\w*|новий|новинка)\w*\b)', | |
| re.IGNORECASE | |
| ) | |
| text = keywords_pattern.sub(r'<span class="highlight">\1</span>', text) | |
| # Возвращаем неразрывные пробелы | |
| text = text.replace("___NBSP___", " ") | |
| return text | |
| def create_card_image(category, bg_prompt, title, bullets, output_filename, is_carousel=False, slide_index=None, total_slides=None): | |
| """ | |
| Создает изображение путем рендеринга HTML/CSS через Headless Chromium. | |
| Поддерживает горизонтальный формат 1080x608 для одиночных постов | |
| и квадратный формат 1080x1080 для каруселей с динамической пагинацией и фоном. | |
| """ | |
| temp_bg_path = os.path.join(OUTPUT_DIR, "temp_bg.png") | |
| temp_html_path = os.path.join(OUTPUT_DIR, "temp_card.html") | |
| final_path = os.path.join(OUTPUT_DIR, output_filename) | |
| # 1. Загружаем чистый фон от Nvidia NIM | |
| success = download_background(bg_prompt, temp_bg_path) | |
| if not success: | |
| print("[Renderer Fallback] Использование локального цвета для фона...") | |
| bg_image_url = "" | |
| else: | |
| with open(temp_bg_path, "rb") as img_file: | |
| header = img_file.read(4) | |
| img_file.seek(0) | |
| encoded_string = base64.b64encode(img_file.read()).decode() | |
| mime_type = "image/png" if header.startswith(b'\x89PNG') else "image/jpeg" | |
| bg_image_url = f"data:{mime_type};base64,{encoded_string}" | |
| # 2. Подготовка разметки | |
| template_path = os.path.join(BASE_DIR, "templates", "card_template.html") | |
| with open(template_path, "r", encoding="utf-8") as f: | |
| html_content = f.read() | |
| # Преобразуем категорию в класс CSS | |
| category_lower = category.lower() | |
| if "іі" in category_lower or "it" in category_lower or "техно" in category_lower or "cyber" in category_lower: | |
| css_class = "cyberpunk" | |
| elif "бізнес" in category_lower or "фінанс" in category_lower or "minimal" in category_lower: | |
| css_class = "minimalism" | |
| else: | |
| css_class = "glassmorphism" | |
| # Экранируем текст, применяем типографику и хайлайтинг | |
| escaped_title = html.escape(title) | |
| escaped_title = apply_typography_rules(escaped_title) | |
| escaped_title = highlight_keywords(escaped_title) | |
| bullets_html = "" | |
| for bullet in bullets: | |
| escaped_bullet = html.escape(bullet) | |
| escaped_bullet = apply_typography_rules(escaped_bullet) | |
| escaped_bullet = highlight_keywords(escaped_bullet) | |
| bullets_html += f"<li>{escaped_bullet}</li>\n" | |
| # Размеры (1080x1080 для каруселей, 1080x608 для одиночных постов) | |
| height = "1080" if is_carousel else "608" | |
| panel_height = "auto" | |
| # Динамическое смещение и вращение фона для карусели | |
| if is_carousel: | |
| shift_x = random.randint(-20, 20) | |
| shift_y = random.randint(-20, 20) | |
| rotate = random.uniform(-1.5, 1.5) | |
| bg_style = f"background-position: calc(50% + {shift_x}px) calc(50% + {shift_y}px); background-size: 115%; transform: scale(1.02) rotate({rotate:.2f}deg);" | |
| panel_style = "" # Карточка строго параллельна краям (угол 0 градусов) | |
| if slide_index is not None and total_slides is not None: | |
| pagination_html = f'<div class="pagination">{slide_index:02d} / {total_slides:02d}</div>' | |
| else: | |
| pagination_html = "" | |
| else: | |
| bg_style = "" | |
| panel_style = "" | |
| pagination_html = "" | |
| # Заменяем плейсхолдеры в шаблоне | |
| html_content = html_content.replace("{HEIGHT}", height) | |
| html_content = html_content.replace("{PANEL_HEIGHT}px", f"{panel_height}px" if panel_height != "auto" else "auto") | |
| html_content = html_content.replace("{CATEGORY_CLASS}", css_class) | |
| html_content = html_content.replace("{BG_IMAGE_URL}", bg_image_url if success else "") | |
| html_content = html_content.replace("{BG_STYLE}", bg_style) | |
| html_content = html_content.replace("{PANEL_STYLE}", panel_style) | |
| html_content = html_content.replace("{PAGINATION_HTML}", pagination_html) | |
| html_content = html_content.replace("{TITLE}", escaped_title) | |
| html_content = html_content.replace("{BULLETS}", bullets_html) | |
| # Записываем временный HTML | |
| with open(temp_html_path, "w", encoding="utf-8") as f: | |
| f.write(html_content) | |
| # 3. Запуск рендеринга через Headless Chromium | |
| print(f"[Renderer] Запуск Chromium для скриншота...") | |
| import shutil | |
| chrome_executable = None | |
| for exe in ["chromium-browser", "chromium", "google-chrome", "google-chrome-stable", "google-chrome-beta", "microsoft-edge"]: | |
| if shutil.which(exe): | |
| chrome_executable = exe | |
| break | |
| if not chrome_executable: | |
| print("[Renderer Error] Критическая ошибка: Не найден браузер Chromium/Chrome/Edge в системе!") | |
| return None | |
| cmd = [ | |
| chrome_executable, | |
| "--headless", | |
| "--disable-gpu", | |
| "--no-sandbox", | |
| "--allow-file-access-from-files", | |
| f"--screenshot={final_path}", | |
| f"--window-size=1080,{height}", | |
| f"file://{temp_html_path}" | |
| ] | |
| try: | |
| subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) | |
| print(f"[Renderer] Изображение успешно отрендерено через HTML/CSS: {final_path}") | |
| except Exception as e: | |
| print(f"[Renderer Error] Критическая ошибка рендеринга через Chromium: {e}") | |
| return None | |
| finally: | |
| # Подчищаем временные файлы | |
| for path in [temp_bg_path, temp_html_path]: | |
| if os.path.exists(path): | |
| try: | |
| os.remove(path) | |
| except Exception: | |
| pass | |
| return final_path | |