Automation_Threads_EU / renderer.py
FincH12's picture
Initial commit from local
9963647 verified
Raw History Blame Contribute Delete
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;", "___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___", "&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