fix
This commit is contained in:
@@ -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";
|
||||
}
|
||||
/**
|
||||
* Генерация изображений.
|
||||
* КЛЮЧЕВОЕ ИЗМЕНЕНИЕ: промпт строится из темы И текста поста — картинка строго в тему.
|
||||
|
||||
Reference in New Issue
Block a user