# -*- 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: """ Автоматически находит триггерные слова и цифры и выделяет их тегом .... """ 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'\1', text) # 2. Выделение триггерных слов динамики/результата keywords_pattern = re.compile( r'(\b(?:подвоїть|подвоїться|потроїть|зменшить|зменшиться|збільшить|збільшиться|впаде|зросте|рекорд|рекордний|рекордно|вперше|критичн\w*|максимальн\w*|катастроф\w*|прорив\w*|новий|новинка)\w*\b)', re.IGNORECASE ) text = keywords_pattern.sub(r'\1', 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"