fix
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user