File size: 11,405 Bytes
9963647 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 | # -*- 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
|