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;", "___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