This commit is contained in:
arys
2026-03-26 14:56:06 +05:00
parent 1fa126286c
commit 3aca7484e8
2 changed files with 312 additions and 254 deletions
@@ -25,23 +25,15 @@ import java.time.Duration;
import java.util.*;
/**
* Сервис генерации видео через Google Veo 3 (с fallback на Veo 2).
* Генерация видео через Google Veo 3 (Vertex AI).
*
* ══ ЗВУК В VEO 3 ══
* Veo 3 добавляет звук АВТОМАТИЧЕСКИ если в промпте описан звук.
* Параметр generateAudio пока НЕ поддерживается через Vertex AI REST API —
* он доступен только через Gemini API (gemini.google.com).
* Через Vertex AI (aiplatform.googleapis.com) звук включается описанием в промпте:
* "фоновая музыка в казахстанском стиле", "закадровый голос на русском" — работает.
* parameters.put("generateAudio", true) — вызывает 400 Bad Request, не используем.
* БЕЗ fallback — если Veo 3 недоступен, явная ошибка в лог.
* Логи: без base64 мусора, только полезная информация.
*
* ══ FALLBACK ══
* Если Veo 3 недоступен (модель ещё не в вашем проекте или 404/403) —
* автоматически пробуем Veo 2. Это решает проблему "видео не генерируются".
*
* ══ ПРОМПТЫ ══
* enhancePrompt = false — ОБЯЗАТЕЛЬНО, иначе Google переводит промпт на английский.
* Все промпты на русском — для казахстанского рынка.
* Если видите в логах:
* "VEO3: HTTP 404" → модель не включена в проекте, запросите доступ
* "VEO3: HTTP 403" → нет IAM прав на модель
* "VEO3: HTTP 400" → неверные параметры запроса
*/
@Service
public class GeminiVideoGenerationService {
@@ -49,9 +41,6 @@ public class GeminiVideoGenerationService {
private static final Logger logger = LoggerFactory.getLogger(GeminiVideoGenerationService.class);
private static final String CREDENTIALS_FILE_PATH = "keys/google-key.json";
// Fallback модель если Veo 3 недоступна в проекте
private static final String FALLBACK_MODEL = "veo-2.0-generate-001";
private final WebClient webClient;
private GoogleCredentials credentials;
private final ObjectMapper objectMapper = new ObjectMapper();
@@ -62,6 +51,7 @@ 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;
@@ -90,284 +80,325 @@ public class GeminiVideoGenerationService {
this.credentials = GoogleCredentials.fromStream(is)
.createScoped(Collections.singletonList(
"https://www.googleapis.com/auth/cloud-platform"));
logger.info("VEO: Credentials loaded. Project={}, Location={}, PrimaryModel={}",
logger.info("VEO3: ✅ Credentials loaded. Project={}, Location={}, Model={}",
projectId, location, model);
}
} else {
logger.error("VEO: CRITICAL — credentials file '{}' not found!", CREDENTIALS_FILE_PATH);
logger.error("VEO3: ❌ Credentials file '{}' NOT FOUND", CREDENTIALS_FILE_PATH);
}
} catch (IOException e) {
logger.error("VEO: Error reading credentials: {}", e.getMessage());
logger.error("VEO3: ❌ Error reading credentials: {}", e.getMessage());
}
}
private String getValidAccessToken() {
if (this.credentials == null) {
logger.error("VEO: Credentials not initialized!");
logger.error("VEO3: ❌ Credentials not initialized");
return null;
}
try {
this.credentials.refreshIfExpired();
String token = this.credentials.getAccessToken().getTokenValue();
if (token == null || token.isEmpty()) {
logger.error("VEO: Token is null/empty after refresh!");
logger.error("VEO3: ❌ Token is empty after refresh");
return null;
}
return token;
} catch (IOException e) {
logger.error("VEO: Token refresh error: {}", e.getMessage());
logger.error("VEO3: ❌ Token refresh failed: {}", e.getMessage());
return null;
}
}
// =====================================================================
// PUBLIC API
// =====================================================================
/**
* Генерирует видео по промпту на РУССКОМ языке.
* Генерирует видео через Veo 3.
*
* Промпт должен содержать описание звука для активации аудио в Veo 3:
* Например: "Звук: современная казахстанская музыка, лёгкий фон"
* Veo 3 поддерживает:
* - Звук: описывай в промпте ("background music", "ambient sounds")
* - Русский язык в промпте (enhancePrompt=false чтобы не переводил)
* - Соотношение 9:16 для вертикального видео
*
* Если Veo 3 недоступен — автоматический fallback на Veo 2.
* @param prompt Промпт на русском языке
* @return Байты MP4 или null при ошибке
*/
public byte[] generateVideo(String prompt) {
if (projectId == null || projectId.trim().isEmpty()) {
logger.error("VEO: Project ID not configured!");
logger.error("VEO3: ❌ Project ID not configured in application.properties");
return null;
}
String accessToken = getValidAccessToken();
if (accessToken == null) return null;
// Сначала пробуем основную модель (Veo 3)
logger.info("VEO: Trying primary model: {}", model);
byte[] result = generateWithModel(prompt, model, accessToken);
// Если не получилось — fallback на Veo 2
if (result == null && !model.equals(FALLBACK_MODEL)) {
logger.warn("VEO: Primary model {} failed. Trying fallback: {}", model, FALLBACK_MODEL);
// Обновляем токен на случай если старый истёк за время первой попытки
String freshToken = getValidAccessToken();
if (freshToken != null) {
result = generateWithModel(prompt, FALLBACK_MODEL, freshToken);
}
if (result != null) {
logger.info("VEO: Fallback model {} succeeded!", FALLBACK_MODEL);
} else {
logger.error("VEO: Both models failed. Video generation unavailable.");
}
try {
return doGenerate(prompt, accessToken);
} catch (Exception e) {
logger.error("VEO3: ❌ Unexpected error: {}", e.getMessage(), e);
return null;
}
return result;
}
/**
* Генерация через конкретную модель Veo.
*/
private byte[] generateWithModel(String prompt, String modelName, String accessToken) {
// =====================================================================
// GENERATION
// =====================================================================
private byte[] doGenerate(String prompt, String accessToken) throws Exception {
// ═══ ШАГ 1: Запуск операции ═══════════════════════════════════════
String generateEndpoint = String.format(
"https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:predictLongRunning",
location, projectId, location, model);
Map<String, Object> instance = new HashMap<>();
instance.put("prompt", prompt);
Map<String, Object> parameters = new HashMap<>();
parameters.put("aspectRatio", "9:16");
parameters.put("sampleCount", 1);
// false = не переводить промпт на английский
parameters.put("enhancePrompt", false);
// Негативный промпт на английском — Veo понимает его лучше
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");
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: {}",
prompt.length() > 300 ? prompt.substring(0, 300) + "..." : prompt);
logger.info("VEO3: Params: aspectRatio=9:16, sampleCount=1, enhancePrompt=false");
Map<String, Object> initResponse;
try {
// ═══ ШАГ 1: Запуск генерации ════════════════════════════════════
String generateEndpoint = String.format(
"https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:predictLongRunning",
location, projectId, location, modelName);
initResponse = webClient.post()
.uri(URI.create(generateEndpoint))
.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(requestBody)
.retrieve()
.bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {})
.block(Duration.ofSeconds(60));
Map<String, Object> instance = new HashMap<>();
instance.put("prompt", prompt);
} catch (WebClientResponseException e) {
// Подробный лог ошибки — без лишнего мусора
logger.error("VEO3: ❌ HTTP {} on START request", e.getStatusCode().value());
logger.error("VEO3: Body: {}", e.getResponseBodyAsString());
Map<String, Object> parameters = new HashMap<>();
parameters.put("aspectRatio", "9:16");
parameters.put("sampleCount", 1);
// КРИТИЧНО: false — иначе Google переводит промпт на английский!
parameters.put("enhancePrompt", false);
// Негативный промпт на русском — единый язык с основным промптом
parameters.put("negativePrompt",
"размытое изображение, плохая анатомия, лишние конечности, " +
"деформированные лица, мультяшный стиль, 3D рендер, " +
"искусственный неестественный вид, субтитры на английском, " +
"водяной знак, нереалистичное движение, некрасивые руки, лишние пальцы, " +
"дёрганые движения, низкое качество, пиксели");
// ВАЖНО: НЕ добавляем generateAudio:true — это вызывает 400 через Vertex AI REST.
// Звук в Veo 3 активируется через описание в промпте (работает через Vertex AI).
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: ПРИЧИНА: Неверные параметры запроса. Проверьте структуру тела запроса");
}
return null;
}
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("instances", Collections.singletonList(instance));
requestBody.put("parameters", parameters);
if (initResponse == null) {
logger.error("VEO3: ❌ Null response on START request");
return null;
}
logger.info("VEO: Starting. Model={}", modelName);
logger.info("VEO: Prompt preview: '{}'",
prompt.length() > 250 ? prompt.substring(0, 250) + "..." : prompt);
String operationName = (String) initResponse.get("name");
if (operationName == null || operationName.isEmpty()) {
logger.error("VEO3: ❌ No 'name' field in response. Full response keys: {}",
initResponse.keySet());
return null;
}
logger.info("VEO3: ✅ Operation started: {}", operationName);
// ═══ ШАГ 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);
Map<String, Object> fetchBody = new HashMap<>();
fetchBody.put("operationName", operationName);
int attempts = 0;
int maxAttempts = 120; // 30 минут (120 × 15 сек)
while (attempts < maxAttempts) {
Thread.sleep(15_000);
attempts++;
String currentToken = getValidAccessToken();
if (currentToken == null) {
logger.warn("VEO3: ⚠ No token on attempt {}, skipping", attempts);
continue;
}
Map<String, Object> initResponse;
try {
initResponse = webClient.post()
.uri(URI.create(generateEndpoint))
.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken)
Map<String, Object> statusResponse = webClient.post()
.uri(URI.create(fetchEndpoint))
.header(HttpHeaders.AUTHORIZATION, "Bearer " + currentToken)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(requestBody)
.bodyValue(fetchBody)
.retrieve()
.bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {})
.block(Duration.ofSeconds(60));
} catch (WebClientResponseException e) {
logger.error("VEO: HTTP {} on initial request (model={}). Body: {}",
e.getStatusCode(), modelName, e.getResponseBodyAsString());
// 404 или 403 — модель недоступна в проекте, возвращаем null для fallback
return null;
}
if (initResponse == null) {
logger.error("VEO: Null initial response for model={}", modelName);
return null;
}
String operationName = (String) initResponse.get("name");
if (operationName == null || operationName.isEmpty()) {
logger.error("VEO: No 'name' in response! model={} response={}",
modelName, objectMapper.writeValueAsString(initResponse));
return null;
}
logger.info("VEO: Operation started: {} (model={})", operationName, modelName);
// ═══ ШАГ 2: Polling ══════════════════════════════════════════════
String fetchEndpoint = String.format(
"https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:fetchPredictOperation",
location, projectId, location, modelName);
Map<String, Object> fetchBody = new HashMap<>();
fetchBody.put("operationName", operationName);
int attempts = 0;
int maxAttempts = 120; // 30 минут
while (attempts < maxAttempts) {
Thread.sleep(15_000);
attempts++;
String currentToken = getValidAccessToken();
if (currentToken == null) {
logger.warn("VEO: No token on attempt {}", attempts);
if (statusResponse == null) {
logger.warn("VEO3: Null status on attempt {}/{}", attempts, maxAttempts);
continue;
}
try {
Map<String, Object> statusResponse = webClient.post()
.uri(URI.create(fetchEndpoint))
.header(HttpHeaders.AUTHORIZATION, "Bearer " + currentToken)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(fetchBody)
.retrieve()
.bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {})
.block(Duration.ofSeconds(60));
if (statusResponse == null) {
logger.warn("VEO: Null status on attempt {}", attempts);
continue;
}
String rawJson = objectMapper.writeValueAsString(statusResponse);
logger.info("VEO: [Attempt {}/{}] model={} raw={}",
attempts, maxAttempts, modelName, rawJson);
if (statusResponse.containsKey("error")) {
logger.error("VEO: Error in response: {}",
objectMapper.writeValueAsString(statusResponse.get("error")));
return null;
}
Object doneObj = statusResponse.get("done");
boolean isDone = Boolean.TRUE.equals(doneObj)
|| "true".equalsIgnoreCase(String.valueOf(doneObj));
if (!isDone) {
logger.info("VEO: Still processing {}/{}", attempts, maxAttempts);
continue;
}
logger.info("VEO: DONE on attempt {}! Extracting video...", attempts);
// ═══ ШАГ 3: Извлечение видео ═════════════════════════════
// Проверяем ошибку
if (statusResponse.containsKey("error")) {
@SuppressWarnings("unchecked")
Map<String, Object> responseObj =
(Map<String, Object>) statusResponse.get("response");
if (responseObj == null) {
logger.warn("VEO: No 'response' field — trying root...");
responseObj = statusResponse;
}
// Вариант 1 — base64
String b64 = findFirstValue(responseObj, "bytesBase64Encoded");
if (b64 != null && !b64.isEmpty()) {
logger.info("VEO: Found base64 (len={})", b64.length());
return decodeBase64(b64);
}
// Вариант 2 — GCS URI
String videoUri = findFirstValue(responseObj, "videoUri");
if (videoUri == null) videoUri = findFirstValue(responseObj, "uri");
if (videoUri != null && !videoUri.isEmpty()) {
logger.info("VEO: Found URI: {}", videoUri);
return downloadFromGcs(videoUri, currentToken);
}
// Вариант 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("VEO: Prediction keys: {}", first.keySet());
b64 = (String) first.get("bytesBase64Encoded");
if (b64 != null && !b64.isEmpty()) return decodeBase64(b64);
String uri = (String) first.get("videoUri");
if (uri == null) uri = (String) first.get("uri");
if (uri != null && !uri.isEmpty()) return downloadFromGcs(uri, currentToken);
}
logger.error("VEO: Video not found! Full: {}", rawJson);
Map<String, Object> error = (Map<String, Object>) statusResponse.get("error");
logger.error("VEO3: ❌ Operation failed with error:");
logger.error("VEO3: code={}, message={}",
error.get("code"), error.get("message"));
return null;
} catch (WebClientResponseException e) {
logger.error("VEO: HTTP {} on attempt {}: {}",
e.getStatusCode(), attempts, e.getResponseBodyAsString());
if (e.getStatusCode().is4xxClientError()) {
logger.error("VEO: 4xx — stopping poll for model={}", modelName);
return null;
}
// 5xx — продолжаем
}
// Проверяем 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));
}
continue;
}
logger.info("VEO3: ✅ DONE on attempt {} (~{}min)", attempts, (attempts * 15 / 60));
// ═══ ШАГ 3: Извлечение видео ═════════════════════════════
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");
}
}
logger.error("VEO3: ❌ TIMEOUT after {} minutes", (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");
if (responseObj == null) {
logger.warn("VEO3: No 'response' field, trying root object");
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());
byte[] decoded = decodeBase64(b64);
if (decoded != null) {
logger.info("VEO3: ✅ Decoded video: {} 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);
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());
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));
return decoded;
}
logger.error("VEO: TIMEOUT after {} min for model={}", (maxAttempts * 15 / 60), modelName);
return null;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
logger.error("VEO: Interrupted");
return null;
} catch (Exception e) {
logger.error("VEO: Fatal error (model={}): {}", modelName, e.getMessage(), e);
return null;
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());
}
}
// Ничего не нашли — логируем только ключи (не значения!)
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());
}
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);
byte[] bytes = Base64.getDecoder().decode(clean);
logger.info("VEO: Decoded {} bytes", bytes.length);
return bytes;
if (clean.contains("base64,")) {
clean = clean.substring(clean.indexOf("base64,") + 7);
}
return Base64.getDecoder().decode(clean);
} catch (Exception e) {
logger.error("VEO: base64 decode failed: {}", e.getMessage());
logger.error("VEO3: ❌ base64 decode failed: {}", e.getMessage());
return null;
}
}
private byte[] downloadFromGcs(String uri, String accessToken) {
if (accessToken == null) {
logger.error("VEO3: ❌ Cannot download GCS — no access token");
return null;
}
try {
String downloadUrl = uri.startsWith("gs://")
? "https://storage.googleapis.com/" + uri.substring(5)
: uri;
logger.info("VEO: Downloading from: {}", downloadUrl);
logger.info("VEO3: Downloading from GCS: {}", downloadUrl);
byte[] bytes = webClient.get()
.uri(URI.create(downloadUrl))
@@ -377,13 +408,15 @@ public class GeminiVideoGenerationService {
.block(Duration.ofMinutes(5));
if (bytes != null && bytes.length > 0) {
logger.info("VEO: Downloaded {} bytes from GCS", bytes.length);
logger.info("VEO3: ✅ Downloaded {} bytes ({} MB) from GCS",
bytes.length, String.format("%.1f", bytes.length / 1024.0 / 1024.0));
return bytes;
}
logger.error("VEO: Empty download from: {}", uri);
logger.error("VEO3: ❌ Empty download from GCS: {}", uri);
return null;
} catch (Exception e) {
logger.error("VEO: GCS download error {}: {}", uri, e.getMessage());
logger.error("VEO3: ❌ GCS download error: {}", e.getMessage());
return null;
}
}
@@ -686,69 +686,94 @@ public class MarketingStrategyV3Service {
*/
private boolean doGenerateVideo(MarketingStrategy.PostCalendarItem item,
String niche, String brand, String city) {
String theme = item.getTheme() != null ? item.getTheme() : "Презентация продукта";
String postText = item.getPostText() != null ? item.getPostText() : "";
String platform = item.getPlatform() != null ? item.getPlatform().toLowerCase() : "instagram";
String platform = item.getPlatform() != null
? item.getPlatform().toLowerCase() : "instagram";
// Извлекаем суть поста для визуализации (первые 100 символов текста)
String postSummary = postText.length() > 100
? postText.substring(0, 100) : postText;
// Берём суть из текста поста — первые 120 символов
String postContext = postText.length() > 120
? postText.substring(0, 120).trim() : postText.trim();
// Стиль по платформе
String styleDesc, audioDesc;
// ── Стиль по платформе (на английском — Veo 3 лучше понимает стиль на EN) ──
String styleEn;
String audioEn;
switch (platform) {
case "tiktok" -> {
styleDesc = "динамичный TikTok-стиль, быстрый монтаж, трендовые переходы, энергичная подача";
audioDesc = "энергичная трендовая музыка для казахстанской молодёжи";
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" -> {
styleDesc = "лаконичный информативный стиль, профессиональный вид";
audioDesc = "спокойная фоновая музыка или закадровый голос на русском";
styleEn = "clean informative style, steady camera, professional look, vertical 9:16";
audioEn = "soft background music, calm ambient, subtle";
}
default -> { // instagram
styleDesc = "кинематографичный Instagram Reels стиль, плавные движения камеры, эстетичная картинка";
audioDesc = "современная казахстанская музыка или атмосферный звук";
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(
"Кинематографичное вертикальное видео 9:16 для %s в Казахстане. " +
"Ниша: %s. Бренд: %s. Город: %s. " +
"ТЕМА ВИДЕО: «%s». " +
"ЧТО ПОКАЗАТЬ: %s. " +
"Стиль: %s. " +
"Звук: %s. " +
"Требования: фотореалистичное 8K, профессиональное освещение, " +
"правильная анатомия, естественные движения и эмоции, " +
"казахстанский контекст — современный город, стильные люди, реальная жизнь. " +
"БЕЗ текстовых оверлеев, БЕЗ водяных знаков, БЕЗ субтитров.",
platform, niche, brand, city, theme, postSummary, styleDesc, audioDesc);
// Часть 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
);
log.info("[VEO3] Generating video. Platform={}, Theme='{}'", platform, theme);
log.debug("[VEO3] Prompt: {}", videoPrompt);
// Сохраняем промпт для отладки
item.setVideoGenerationPrompt(videoPrompt);
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);
try {
byte[] videoBytes = geminiVideoService.generateVideo(videoPrompt);
if (videoBytes != null && videoBytes.length > 0) {
String filename = "video_" + System.currentTimeMillis()
+ "_" + Math.abs(item.hashCode()) + ".mp4";
minIOService.uploadFile(filename, videoBytes, "video/mp4");
item.setVideoUrl(filename);
item.setVideoFilename(filename);
item.setVideoGenerationPrompt(videoPrompt); // сохраняем промпт для отладки
item.setImageUrl(null);
item.setImageFilename(null);
log.info("[VEO3] ✅ Saved: {} ({} bytes)", filename, videoBytes.length);
log.info("[VEO3] ✅ Saved: {} ({} MB)",
filename, String.format("%.1f", videoBytes.length / 1024.0 / 1024.0));
return true;
} else {
log.warn("[VEO3] Empty response for theme '{}'", theme);
log.error("[VEO3] Empty response for theme '{}'", theme);
log.error("[VEO3] Проверьте логи GeminiVideoGenerationService для причины ошибки");
item.setVideoFilename(null);
item.setVideoUrl(null);
return false;
}
} catch (Exception e) {
log.error("[VEO3] Error for '{}': {}", theme, e.getMessage());
log.error("[VEO3] ❌ Exception for theme '{}': {}", theme, e.getMessage(), e);
item.setVideoFilename(null);
item.setVideoUrl(null);
return false;