This commit is contained in:
arys
2026-03-26 15:02:42 +05:00
parent 3aca7484e8
commit 46bf6adc04
2 changed files with 203 additions and 207 deletions
@@ -27,13 +27,11 @@ import java.util.*;
/**
* Генерация видео через Google Veo 3 (Vertex AI).
*
* БЕЗ fallback — если Veo 3 недоступен, явная ошибка в лог.
* Логи: без base64 мусора, только полезная информация.
* КЛЮЧЕВОЙ ФАКТ: Veo 3 требует enhancePrompt=true ОБЯЗАТЕЛЬНО.
* Поэтому промпт пишем на английском — enhance улучшит его,
* а не переведёт с русского криво.
*
* Если видите в логах:
* "VEO3: HTTP 404" → модель не включена в проекте, запросите доступ
* "VEO3: HTTP 403" → нет IAM прав на модель
* "VEO3: HTTP 400" → неверные параметры запроса
* Veo 3 поддерживает звук нативно — описываем audio в промпте.
*/
@Service
public class GeminiVideoGenerationService {
@@ -51,7 +49,6 @@ public class GeminiVideoGenerationService {
@Value("${google.cloud.location:us-central1}")
private String location;
// Только Veo 3 — без fallback
@Value("${google.gemini.video.model:veo-3.0-generate-001}")
private String model;
@@ -110,45 +107,32 @@ public class GeminiVideoGenerationService {
}
}
// =====================================================================
// PUBLIC API
// =====================================================================
/**
* Генерирует видео через Veo 3.
* Генерирует видео. Принимает промпт на АНГЛИЙСКОМ языке.
* Промпт строится в MarketingStrategyV3Service.buildVideoPromptEnglish()
*
* Veo 3 поддерживает:
* - Звук: описывай в промпте ("background music", "ambient sounds")
* - Русский язык в промпте (enhancePrompt=false чтобы не переводил)
* - Соотношение 9:16 для вертикального видео
*
* @param prompt Промпт на русском языке
* @param englishPrompt Промпт на английском языке
* @return Байты MP4 или null при ошибке
*/
public byte[] generateVideo(String prompt) {
public byte[] generateVideo(String englishPrompt) {
if (projectId == null || projectId.trim().isEmpty()) {
logger.error("VEO3: ❌ Project ID not configured in application.properties");
logger.error("VEO3: ❌ Project ID not configured");
return null;
}
String accessToken = getValidAccessToken();
if (accessToken == null) return null;
try {
return doGenerate(prompt, accessToken);
return doGenerate(englishPrompt, accessToken);
} catch (Exception e) {
logger.error("VEO3: ❌ Unexpected error: {}", e.getMessage(), e);
return null;
}
}
// =====================================================================
// GENERATION
// =====================================================================
private byte[] doGenerate(String prompt, String accessToken) throws Exception {
// ═══ ШАГ 1: Запуск операции ═══════════════════════════════════════
// ═══ ШАГ 1: Запуск ═══════════════════════════════════════════════
String generateEndpoint = String.format(
"https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:predictLongRunning",
location, projectId, location, model);
@@ -159,25 +143,21 @@ public class GeminiVideoGenerationService {
Map<String, Object> parameters = new HashMap<>();
parameters.put("aspectRatio", "9:16");
parameters.put("sampleCount", 1);
// false = не переводить промпт на английский
parameters.put("enhancePrompt", false);
// Негативный промпт на английском — Veo понимает его лучше
// Veo 3 ТРЕБУЕТ enhancePrompt=true — нельзя отключить (code=3 если false)
parameters.put("enhancePrompt", true);
parameters.put("negativePrompt",
"text overlay, subtitles, captions, watermark, on-screen text, " +
"blurry, low quality, bad anatomy, extra limbs, deformed face, " +
"cartoon, CGI look, 3D render, artificial, jerky motion, " +
"extra fingers, distorted proportions");
"cartoon, 3D render, CGI, artificial look, jerky motion, " +
"extra fingers, distorted proportions, nsfw");
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("instances", Collections.singletonList(instance));
requestBody.put("parameters", parameters);
logger.info("VEO3: ▶ Starting generation");
logger.info("VEO3: Model: {}", model);
logger.info("VEO3: Endpoint: {}", generateEndpoint);
logger.info("VEO3: Prompt: {}",
logger.info("VEO3: ▶ Starting generation | Model={}", model);
logger.info("VEO3: Prompt: {}",
prompt.length() > 300 ? prompt.substring(0, 300) + "..." : prompt);
logger.info("VEO3: Params: aspectRatio=9:16, sampleCount=1, enhancePrompt=false");
Map<String, Object> initResponse;
try {
@@ -191,36 +171,28 @@ public class GeminiVideoGenerationService {
.block(Duration.ofSeconds(60));
} catch (WebClientResponseException e) {
// Подробный лог ошибки — без лишнего мусора
logger.error("VEO3: ❌ HTTP {} on START request", e.getStatusCode().value());
logger.error("VEO3: Body: {}", e.getResponseBodyAsString());
if (e.getStatusCode().value() == 404) {
logger.error("VEO3: ПРИЧИНА: Модель '{}' недоступна в проекте '{}'", model, projectId);
logger.error("VEO3: РЕШЕНИЕ: Запросите доступ к Veo 3 на https://cloud.google.com/vertex-ai/generative-ai/docs/video/generate-videos");
} else if (e.getStatusCode().value() == 403) {
logger.error("VEO3: ПРИЧИНА: Нет IAM прав. Сервис-аккаунт должен иметь роль 'Vertex AI User'");
} else if (e.getStatusCode().value() == 400) {
logger.error("VEO3: ПРИЧИНА: Неверные параметры запроса. Проверьте структуру тела запроса");
}
logger.error("VEO3: ❌ HTTP {} on START | Body: {}",
e.getStatusCode().value(), e.getResponseBodyAsString());
if (e.getStatusCode().value() == 404)
logger.error("VEO3: → Модель недоступна. Запросите доступ к Veo 3 в Google Cloud Console");
if (e.getStatusCode().value() == 403)
logger.error("VEO3: → Нет IAM прав. Добавьте роль 'Vertex AI User' сервис-аккаунту");
return null;
}
if (initResponse == null) {
logger.error("VEO3: ❌ Null response on START request");
logger.error("VEO3: ❌ Null response on START");
return null;
}
String operationName = (String) initResponse.get("name");
if (operationName == null || operationName.isEmpty()) {
logger.error("VEO3: ❌ No 'name' field in response. Full response keys: {}",
initResponse.keySet());
logger.error("VEO3: ❌ No 'name' in response. Keys: {}", initResponse.keySet());
return null;
}
logger.info("VEO3: ✅ Operation: {}", operationName);
logger.info("VEO3: ✅ Operation started: {}", operationName);
// ═══ ШАГ 2: Polling ════════════════════════════════════════════════
// ═══ ШАГ 2: Polling ═══════════════════════════════════════════════
String fetchEndpoint = String.format(
"https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:fetchPredictOperation",
location, projectId, location, model);
@@ -228,8 +200,7 @@ public class GeminiVideoGenerationService {
Map<String, Object> fetchBody = new HashMap<>();
fetchBody.put("operationName", operationName);
int attempts = 0;
int maxAttempts = 120; // 30 минут (120 × 15 сек)
int attempts = 0, maxAttempts = 120;
while (attempts < maxAttempts) {
Thread.sleep(15_000);
@@ -237,7 +208,7 @@ public class GeminiVideoGenerationService {
String currentToken = getValidAccessToken();
if (currentToken == null) {
logger.warn("VEO3: ⚠ No token on attempt {}, skipping", attempts);
logger.warn("VEO3: ⚠ No token on attempt {}", attempts);
continue;
}
@@ -252,135 +223,94 @@ public class GeminiVideoGenerationService {
.block(Duration.ofSeconds(60));
if (statusResponse == null) {
logger.warn("VEO3: ⚠ Null status on attempt {}/{}", attempts, maxAttempts);
logger.warn("VEO3: ⚠ Null status on attempt {}", attempts);
continue;
}
// Проверяем ошибку
if (statusResponse.containsKey("error")) {
@SuppressWarnings("unchecked")
Map<String, Object> error = (Map<String, Object>) statusResponse.get("error");
logger.error("VEO3: ❌ Operation failed with error:");
logger.error("VEO3: code={}, message={}",
logger.error("VEO3: ❌ Operation error: code={}, message={}",
error.get("code"), error.get("message"));
return null;
}
// Проверяем done
Object doneObj = statusResponse.get("done");
boolean isDone = Boolean.TRUE.equals(doneObj)
|| "true".equalsIgnoreCase(String.valueOf(doneObj));
if (!isDone) {
// Показываем прогресс каждые 5 попыток
if (attempts % 5 == 0 || attempts <= 3) {
logger.info("VEO3: ⏳ Still processing... attempt {}/{} (~{}min elapsed)",
attempts, maxAttempts, (attempts * 15 / 60));
}
if (attempts % 4 == 0 || attempts <= 2)
logger.info("VEO3: ⏳ Processing... {}/{} (~{}min)",
attempts, maxAttempts, attempts * 15 / 60);
continue;
}
logger.info("VEO3: ✅ DONE on attempt {} (~{}min)", attempts, (attempts * 15 / 60));
// ═══ ШАГ 3: Извлечение видео ═════════════════════════════
logger.info("VEO3: ✅ DONE on attempt {} (~{}min)", attempts, attempts * 15 / 60);
return extractVideo(statusResponse);
} catch (WebClientResponseException e) {
logger.error("VEO3: ❌ HTTP {} on attempt {}: {}",
e.getStatusCode().value(), attempts, e.getResponseBodyAsString());
if (e.getStatusCode().is4xxClientError()) {
logger.error("VEO3: 4xx error — stopping poll");
return null;
}
// 5xx — продолжаем
logger.warn("VEO3: 5xx error — will retry");
if (e.getStatusCode().is4xxClientError()) return null;
}
}
logger.error("VEO3: ❌ TIMEOUT after {} minutes", (maxAttempts * 15 / 60));
logger.error("VEO3: ❌ TIMEOUT after {} min", maxAttempts * 15 / 60);
return null;
}
// =====================================================================
// VIDEO EXTRACTION — без логирования base64
// =====================================================================
private byte[] extractVideo(Map<String, Object> statusResponse) {
@SuppressWarnings("unchecked")
Map<String, Object> responseObj = (Map<String, Object>) statusResponse.get("response");
Map<String, Object> responseObj =
(Map<String, Object>) statusResponse.get("response");
if (responseObj == null) {
logger.warn("VEO3: No 'response' field, trying root object");
logger.warn("VEO3: No 'response' field, trying root");
responseObj = statusResponse;
}
// Вариант 1 — base64 (НЕ логируем значение — это мусор в логах)
String b64 = findFirstValue(responseObj, "bytesBase64Encoded");
if (b64 != null && !b64.isEmpty()) {
logger.info("VEO3: Found base64 encoded video (length={}chars)", b64.length());
logger.info("VEO3: Found base64 ({}chars)", b64.length());
byte[] decoded = decodeBase64(b64);
if (decoded != null) {
logger.info("VEO3: ✅ Decoded video: {} bytes ({} MB)",
if (decoded != null)
logger.info("VEO3: ✅ Video decoded: {} bytes ({} MB)",
decoded.length, String.format("%.1f", decoded.length / 1024.0 / 1024.0));
}
return decoded;
}
// Вариант 2 — GCS URI
String videoUri = findFirstValue(responseObj, "videoUri");
if (videoUri == null) videoUri = findFirstValue(responseObj, "uri");
if (videoUri != null && !videoUri.isEmpty()) {
logger.info("VEO3: Found video URI: {}", videoUri);
logger.info("VEO3: Found URI: {}", videoUri);
return downloadFromGcs(videoUri, getValidAccessToken());
}
// Вариант 3 — predictions
@SuppressWarnings("unchecked")
List<Map<String, Object>> predictions =
(List<Map<String, Object>>) responseObj.get("predictions");
if (predictions != null && !predictions.isEmpty()) {
Map<String, Object> first = predictions.get(0);
logger.info("VEO3: Checking predictions[0]. Keys: {}", first.keySet());
logger.info("VEO3: predictions[0] keys: {}", first.keySet());
b64 = (String) first.get("bytesBase64Encoded");
if (b64 != null && !b64.isEmpty()) {
logger.info("VEO3: Found base64 in predictions (length={}chars)", b64.length());
byte[] decoded = decodeBase64(b64);
if (decoded != null)
logger.info("VEO3: ✅ Decoded: {} bytes ({} MB)",
decoded.length, String.format("%.1f", decoded.length / 1024.0 / 1024.0));
logger.info("VEO3: ✅ Video from predictions: {} bytes", decoded.length);
return decoded;
}
String uri = (String) first.get("videoUri");
if (uri == null) uri = (String) first.get("uri");
if (uri != null && !uri.isEmpty()) {
logger.info("VEO3: Found URI in predictions: {}", uri);
return downloadFromGcs(uri, getValidAccessToken());
}
String uri = (String) first.getOrDefault("videoUri", first.get("uri"));
if (uri != null && !uri.isEmpty()) return downloadFromGcs(uri, getValidAccessToken());
}
// Ничего не нашли — логируем только ключи (не значения!)
logger.error("VEO3: ❌ Video not found in response!");
logger.error("VEO3: statusResponse keys: {}", statusResponse.keySet());
if (responseObj != statusResponse) {
logger.error("VEO3: responseObj keys: {}", responseObj.keySet());
}
if (predictions != null && !predictions.isEmpty()) {
logger.error("VEO3: predictions[0] keys: {}", predictions.get(0).keySet());
}
logger.error("VEO3: ❌ Video not found. Response keys: {}", responseObj.keySet());
return null;
}
// =====================================================================
// HELPERS
// =====================================================================
private byte[] decodeBase64(String b64) {
try {
String clean = b64.replaceAll("\\s", "");
if (clean.contains("base64,")) {
clean = clean.substring(clean.indexOf("base64,") + 7);
}
if (clean.contains("base64,")) clean = clean.substring(clean.indexOf("base64,") + 7);
return Base64.getDecoder().decode(clean);
} catch (Exception e) {
logger.error("VEO3: ❌ base64 decode failed: {}", e.getMessage());
@@ -389,54 +319,43 @@ public class GeminiVideoGenerationService {
}
private byte[] downloadFromGcs(String uri, String accessToken) {
if (accessToken == null) {
logger.error("VEO3: ❌ Cannot download GCS — no access token");
return null;
}
if (accessToken == null) return null;
try {
String downloadUrl = uri.startsWith("gs://")
? "https://storage.googleapis.com/" + uri.substring(5)
: uri;
logger.info("VEO3: Downloading from GCS: {}", downloadUrl);
String url = uri.startsWith("gs://")
? "https://storage.googleapis.com/" + uri.substring(5) : uri;
logger.info("VEO3: Downloading from GCS: {}", url);
byte[] bytes = webClient.get()
.uri(URI.create(downloadUrl))
.uri(URI.create(url))
.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken)
.retrieve()
.bodyToMono(byte[].class)
.block(Duration.ofMinutes(5));
.retrieve().bodyToMono(byte[].class).block(Duration.ofMinutes(5));
if (bytes != null && bytes.length > 0) {
logger.info("VEO3: ✅ Downloaded {} bytes ({} MB) from GCS",
bytes.length, String.format("%.1f", bytes.length / 1024.0 / 1024.0));
logger.info("VEO3: ✅ GCS download: {} bytes", bytes.length);
return bytes;
}
logger.error("VEO3: ❌ Empty download from GCS: {}", uri);
logger.error("VEO3: ❌ Empty GCS download");
return null;
} catch (Exception e) {
logger.error("VEO3: ❌ GCS download error: {}", e.getMessage());
logger.error("VEO3: ❌ GCS error: {}", e.getMessage());
return null;
}
}
private String findFirstValue(Object obj, String targetKey) {
private String findFirstValue(Object obj, String key) {
if (obj instanceof Map) {
@SuppressWarnings("unchecked")
Map<String, Object> map = (Map<String, Object>) obj;
if (map.containsKey(targetKey)) {
Object val = map.get(targetKey);
if (map.containsKey(key)) {
Object val = map.get(key);
if (val instanceof String s && !s.isEmpty()) return s;
}
for (Object value : map.values()) {
String found = findFirstValue(value, targetKey);
if (found != null) return found;
for (Object v : map.values()) {
String f = findFirstValue(v, key);
if (f != null) return f;
}
} else if (obj instanceof List) {
for (Object item : (List<?>) obj) {
String found = findFirstValue(item, targetKey);
if (found != null) return found;
String f = findFirstValue(item, key);
if (f != null) return f;
}
}
return null;
@@ -687,72 +687,27 @@ public class MarketingStrategyV3Service {
private boolean doGenerateVideo(MarketingStrategy.PostCalendarItem item,
String niche, String brand, String city) {
String theme = item.getTheme() != null ? item.getTheme() : "Презентация продукта";
String theme = item.getTheme() != null ? item.getTheme() : "Product presentation";
String postText = item.getPostText() != null ? item.getPostText() : "";
String platform = item.getPlatform() != null
? item.getPlatform().toLowerCase() : "instagram";
// Берём суть из текста поста — первые 120 символов
String postContext = postText.length() > 120
? postText.substring(0, 120).trim() : postText.trim();
// Строим промпт на английском
String englishPrompt = buildVideoPromptEnglish(theme, postText, niche, brand, city, platform);
// ── Стиль по платформе (на английском — Veo 3 лучше понимает стиль на EN) ──
String styleEn;
String audioEn;
switch (platform) {
case "tiktok" -> {
styleEn = "dynamic TikTok Reels style, fast cuts, trendy transitions, " +
"energetic handheld camera, vertical 9:16";
audioEn = "upbeat modern Kazakh music, energetic rhythm, no vocals";
}
case "telegram" -> {
styleEn = "clean informative style, steady camera, professional look, vertical 9:16";
audioEn = "soft background music, calm ambient, subtle";
}
default -> { // instagram
styleEn = "cinematic Instagram Reels style, smooth camera movement, " +
"aesthetic lifestyle feel, warm color grading, vertical 9:16";
audioEn = "modern ambient music, soft background, atmospheric, no lyrics";
}
}
// ── Финальный промпт: русский контекст + английские технические команды ──
// Структура: [ЧТО ПОКАЗАТЬ на русском] + [КАК СНЯТЬ на английском]
// Это даёт Veo 3 лучший результат — понимает контент И технические требования
String videoPrompt = String.format(
// Часть 1: ЧТО показывать (контекст ниши — на русском)
"Видео для бизнеса в нише «%s», бренд «%s», город %s. " +
"Тема: «%s». " +
"Показать: %s. " +
// Часть 2: КАК снимать (технические требования — на английском для точности)
"Cinematic photorealistic 8K vertical video. " +
"Style: %s. " +
// ЗВУК — явное указание для Veo 3
"Audio: %s. " +
// КРИТИЧНО: запрет текста в кадре
"IMPORTANT: NO text on screen, NO subtitles, NO captions, NO watermarks, NO logos. " +
// Требования к людям и качеству
"People: natural Kazakh appearance, authentic emotions, realistic proportions. " +
"Quality: sharp focus, professional lighting, no blur, no CGI look.",
niche, brand, city,
theme,
postContext.isEmpty() ? theme : postContext,
styleEn,
audioEn
);
// Сохраняем промпт для отладки
item.setVideoGenerationPrompt(videoPrompt);
// Сохраняем для отладки
item.setVideoGenerationPrompt(englishPrompt);
log.info("[VEO3] Generating video:");
log.info("[VEO3] Platform = {}", platform);
log.info("[VEO3] Theme = {}", theme);
log.info("[VEO3] Niche = {} | Brand = {} | City = {}", niche, brand, city);
log.info("[VEO3] Prompt = {}",
videoPrompt.length() > 400 ? videoPrompt.substring(0, 400) + "..." : videoPrompt);
log.info("[VEO3] Platform : {}", platform);
log.info("[VEO3] Theme : {}", theme);
log.info("[VEO3] Niche : {} | Brand : {} | City : {}", niche, brand, city);
log.info("[VEO3] Prompt : {}",
englishPrompt.length() > 400
? englishPrompt.substring(0, 400) + "..." : englishPrompt);
try {
byte[] videoBytes = geminiVideoService.generateVideo(videoPrompt);
byte[] videoBytes = geminiVideoService.generateVideo(englishPrompt);
if (videoBytes != null && videoBytes.length > 0) {
String filename = "video_" + System.currentTimeMillis()
@@ -766,8 +721,7 @@ public class MarketingStrategyV3Service {
filename, String.format("%.1f", videoBytes.length / 1024.0 / 1024.0));
return true;
} else {
log.error("[VEO3] ❌ Empty response for theme '{}'", theme);
log.error("[VEO3] Проверьте логи GeminiVideoGenerationService для причины ошибки");
log.error("[VEO3] ❌ Empty response for theme: '{}'", theme);
item.setVideoFilename(null);
item.setVideoUrl(null);
return false;
@@ -780,6 +734,129 @@ public class MarketingStrategyV3Service {
}
}
/**
* Строит промпт для Veo 3 на английском языке.
*
* Структура промпта:
* [SCENE] → что происходит в кадре
* [STYLE] → как снято, монтаж, операторская работа
* [AUDIO] → звук (Veo 3 поддерживает нативно)
* [RULES] → что запрещено (текст в кадре и т.д.)
*/
private String buildVideoPromptEnglish(String theme, String postText,
String niche, String brand,
String city, String platform) {
// Первые 100 символов текста поста — для контекста сцены
String sceneContext = postText.length() > 100
? postText.substring(0, 100).trim() : postText.trim();
// Убираем кириллицу из контекста для промпта — оставляем только смысл
// (Переводить не нужно — Veo 3 enhance сам улучшит EN-промпт)
// Описываем тему своими словами на EN
// Определяем тип сцены по теме поста
String sceneType = resolveSceneType(theme, niche);
// Стиль и звук по платформе
String style, audio;
switch (platform) {
case "tiktok" -> {
style = "dynamic TikTok Reels style, fast energetic cuts, trendy transitions, " +
"handheld camera movement, authentic street style";
audio = "upbeat modern music, energetic rhythm, suitable for young Kazakh audience, " +
"no lyrics, ambient background";
}
case "telegram" -> {
style = "clean professional look, steady camera, informative documentary style";
audio = "calm soft background music, subtle ambient sounds, professional feel";
}
default -> { // instagram
style = "cinematic Instagram Reels, smooth gimbal movement, warm color grading, " +
"aesthetic lifestyle composition, shallow depth of field";
audio = "modern soft background music, atmospheric ambient, " +
"natural environment sounds, warm and inviting";
}
}
return String.format(
// СЦЕНА
"Vertical 9:16 cinematic video for %s business in %s, Kazakhstan. " +
"Business niche: %s. Brand: %s. " +
"Scene: %s. " +
"Visual concept: %s. " +
// ЛЮДИ
"People: Kazakh appearance, natural authentic look, " +
"real emotions, perfect anatomy, photorealistic. " +
// СТИЛЬ
"Style: %s. " +
"Quality: 8K resolution, professional lighting, sharp focus, " +
"no blur, no CGI, photorealistic, commercial grade. " +
// ЗВУК — явно для Veo 3
"Sound design: %s. " +
// ЗАПРЕТЫ — критично важны
"STRICT RULES: " +
"NO text on screen. " +
"NO subtitles. " +
"NO captions. " +
"NO watermarks. " +
"NO on-screen graphics. " +
"NO logos overlaid. " +
"NO artificial CGI look.",
platform, city,
niche, brand,
sceneType,
theme,
style,
audio
);
}
/**
* Определяет тип визуальной сцены по теме поста.
* Переводит суть темы в описание конкретной сцены на английском.
*/
private String resolveSceneType(String theme, String niche) {
if (theme == null) return "business activity scene in " + niche;
String t = theme.toLowerCase();
// Кейсы и результаты
if (t.contains("кейс") || t.contains("результат") || t.contains("успех") || t.contains("история"))
return "happy satisfied client sharing their success story, before/after transformation";
// Обучение и советы
if (t.contains("совет") || t.contains("как") || t.contains("обучен") || t.contains("урок") || t.contains("разбор"))
return "expert explaining key tips and techniques in " + niche + ", educational demonstration";
// Процесс работы
if (t.contains("процесс") || t.contains("работа") || t.contains("как мы") || t.contains("за кадром"))
return "behind the scenes work process, professional team in action, " + niche + " workflow";
// Отзывы
if (t.contains("отзыв") || t.contains("клиент") || t.contains("мнение"))
return "real client testimonial, satisfied customer talking about their experience";
// Продажи / акции
if (t.contains("акци") || t.contains("скидк") || t.contains("предложен") || t.contains("оффер"))
return "attractive product/service showcase with special offer highlight, " + niche;
// Демонстрация продукта
if (t.contains("продукт") || t.contains("услуг") || t.contains("обзор") || t.contains("демонстрац"))
return "detailed product/service demonstration, close-up shots, " + niche;
// Команда
if (t.contains("команда") || t.contains("специалист") || t.contains("эксперт"))
return "professional team introduction, confident experts in " + niche + " environment";
// Вопрос-ответ / вовлечение
if (t.contains("вопрос") || t.contains("голосован") || t.contains("опрос"))
return "engaging interactive moment, person asking audience a relatable question about " + niche;
// По умолчанию — общая сцена из ниши
return "authentic lifestyle scene showing " + niche + " activity, " +
"real people benefiting from the service/product";
}
/**
* Генерация изображений.
* КЛЮЧЕВОЕ ИЗМЕНЕНИЕ: промпт строится из темы И текста поста — картинка строго в тему.