fix
This commit is contained in:
@@ -614,7 +614,7 @@ public class MarketingAnalysisV3Controller {
|
||||
if (title.length() > 80) {
|
||||
title = title.substring(0, 77) + "...";
|
||||
}
|
||||
|
||||
|
||||
response.setAnalysisTitle(title);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -25,12 +25,23 @@ import java.time.Duration;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Сервис генерации видео через Google Veo 3.
|
||||
* Сервис генерации видео через Google Veo 3 (с fallback на Veo 2).
|
||||
*
|
||||
* КЛЮЧЕВЫЕ ОСОБЕННОСТИ Veo 3:
|
||||
* - Нативный звук: музыка, диалоги, фоновые звуки — без доп. настроек
|
||||
* - enhancePrompt = false — ОБЯЗАТЕЛЬНО, иначе Google переводит промпт на английский
|
||||
* - Все промпты передаются на русском языке для казахстанского рынка
|
||||
* ══ ЗВУК В 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 недоступен (модель ещё не в вашем проекте или 404/403) —
|
||||
* автоматически пробуем Veo 2. Это решает проблему "видео не генерируются".
|
||||
*
|
||||
* ══ ПРОМПТЫ ══
|
||||
* enhancePrompt = false — ОБЯЗАТЕЛЬНО, иначе Google переводит промпт на английский.
|
||||
* Все промпты на русском — для казахстанского рынка.
|
||||
*/
|
||||
@Service
|
||||
public class GeminiVideoGenerationService {
|
||||
@@ -38,6 +49,9 @@ 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();
|
||||
@@ -76,32 +90,32 @@ public class GeminiVideoGenerationService {
|
||||
this.credentials = GoogleCredentials.fromStream(is)
|
||||
.createScoped(Collections.singletonList(
|
||||
"https://www.googleapis.com/auth/cloud-platform"));
|
||||
logger.info("VEO3: Credentials loaded. Project={}, Location={}, Model={}",
|
||||
logger.info("VEO: Credentials loaded. Project={}, Location={}, PrimaryModel={}",
|
||||
projectId, location, model);
|
||||
}
|
||||
} else {
|
||||
logger.error("VEO3: CRITICAL — credentials file '{}' not found!", CREDENTIALS_FILE_PATH);
|
||||
logger.error("VEO: CRITICAL — credentials file '{}' not found!", CREDENTIALS_FILE_PATH);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
logger.error("VEO3: Error reading credentials: {}", e.getMessage());
|
||||
logger.error("VEO: Error reading credentials: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private String getValidAccessToken() {
|
||||
if (this.credentials == null) {
|
||||
logger.error("VEO3: Credentials not initialized!");
|
||||
logger.error("VEO: Credentials not initialized!");
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
this.credentials.refreshIfExpired();
|
||||
String token = this.credentials.getAccessToken().getTokenValue();
|
||||
if (token == null || token.isEmpty()) {
|
||||
logger.error("VEO3: Access token is null/empty after refresh!");
|
||||
logger.error("VEO: Token is null/empty after refresh!");
|
||||
return null;
|
||||
}
|
||||
return token;
|
||||
} catch (IOException e) {
|
||||
logger.error("VEO3: Token refresh error: {}", e.getMessage());
|
||||
logger.error("VEO: Token refresh error: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -109,24 +123,51 @@ public class GeminiVideoGenerationService {
|
||||
/**
|
||||
* Генерирует видео по промпту на РУССКОМ языке.
|
||||
*
|
||||
* Промпт формируется в MarketingStrategyV3Service.doGenerateVideo()
|
||||
* с учётом казахстанского контекста, города, платформы и темы поста.
|
||||
* Veo 3 автоматически добавляет звук (музыку/диалоги) к видео.
|
||||
* Промпт должен содержать описание звука для активации аудио в Veo 3:
|
||||
* Например: "Звук: современная казахстанская музыка, лёгкий фон"
|
||||
*
|
||||
* Если Veo 3 недоступен — автоматический fallback на Veo 2.
|
||||
*/
|
||||
public byte[] generateVideo(String prompt) {
|
||||
if (projectId == null || projectId.trim().isEmpty()) {
|
||||
logger.error("VEO3: Project ID is not configured!");
|
||||
logger.error("VEO: Project ID not configured!");
|
||||
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.");
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Генерация через конкретную модель Veo.
|
||||
*/
|
||||
private byte[] generateWithModel(String prompt, String modelName, String accessToken) {
|
||||
try {
|
||||
// ═══ ШАГ 1: Запуск генерации через predictLongRunning ════════════
|
||||
// ═══ ШАГ 1: Запуск генерации ════════════════════════════════════
|
||||
String generateEndpoint = String.format(
|
||||
"https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:predictLongRunning",
|
||||
location, projectId, location, model);
|
||||
location, projectId, location, modelName);
|
||||
|
||||
Map<String, Object> instance = new HashMap<>();
|
||||
instance.put("prompt", prompt);
|
||||
@@ -134,21 +175,25 @@ public class GeminiVideoGenerationService {
|
||||
Map<String, Object> parameters = new HashMap<>();
|
||||
parameters.put("aspectRatio", "9:16");
|
||||
parameters.put("sampleCount", 1);
|
||||
// КРИТИЧНО: false — не давать Google переводить промпт на английский
|
||||
// КРИТИЧНО: false — иначе Google переводит промпт на английский!
|
||||
parameters.put("enhancePrompt", false);
|
||||
// Негативный промпт тоже на русском
|
||||
// Негативный промпт на русском — единый язык с основным промптом
|
||||
parameters.put("negativePrompt",
|
||||
"размытое изображение, плохая анатомия, лишние конечности, деформированные лица, " +
|
||||
"мультяшный стиль, 3D рендер, искусственный вид, субтитры на английском, " +
|
||||
"водяной знак, нереалистичное движение, некрасивые руки, лишние пальцы");
|
||||
"размытое изображение, плохая анатомия, лишние конечности, " +
|
||||
"деформированные лица, мультяшный стиль, 3D рендер, " +
|
||||
"искусственный неестественный вид, субтитры на английском, " +
|
||||
"водяной знак, нереалистичное движение, некрасивые руки, лишние пальцы, " +
|
||||
"дёрганые движения, низкое качество, пиксели");
|
||||
// ВАЖНО: НЕ добавляем generateAudio:true — это вызывает 400 через Vertex AI REST.
|
||||
// Звук в Veo 3 активируется через описание в промпте (работает через Vertex AI).
|
||||
|
||||
Map<String, Object> requestBody = new HashMap<>();
|
||||
requestBody.put("instances", Collections.singletonList(instance));
|
||||
requestBody.put("parameters", parameters);
|
||||
|
||||
logger.info("VEO3: Starting generation. Model={}, Endpoint={}", model, generateEndpoint);
|
||||
logger.info("VEO3: Prompt (preview 300): '{}'",
|
||||
prompt.length() > 300 ? prompt.substring(0, 300) + "..." : prompt);
|
||||
logger.info("VEO: Starting. Model={}", modelName);
|
||||
logger.info("VEO: Prompt preview: '{}'",
|
||||
prompt.length() > 250 ? prompt.substring(0, 250) + "..." : prompt);
|
||||
|
||||
Map<String, Object> initResponse;
|
||||
try {
|
||||
@@ -159,46 +204,45 @@ public class GeminiVideoGenerationService {
|
||||
.bodyValue(requestBody)
|
||||
.retrieve()
|
||||
.bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {})
|
||||
.block(Duration.ofSeconds(120));
|
||||
.block(Duration.ofSeconds(60));
|
||||
} catch (WebClientResponseException e) {
|
||||
logger.error("VEO3: HTTP {} on initial request. Body: {}",
|
||||
e.getStatusCode(), e.getResponseBodyAsString());
|
||||
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("VEO3: Initial response is null!");
|
||||
logger.error("VEO: Null initial response for model={}", modelName);
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.info("VEO3: Initial response: {}", objectMapper.writeValueAsString(initResponse));
|
||||
|
||||
String operationName = (String) initResponse.get("name");
|
||||
if (operationName == null || operationName.isEmpty()) {
|
||||
logger.error("VEO3: No 'name' field! Full response: {}",
|
||||
objectMapper.writeValueAsString(initResponse));
|
||||
logger.error("VEO: No 'name' in response! model={} response={}",
|
||||
modelName, objectMapper.writeValueAsString(initResponse));
|
||||
return null;
|
||||
}
|
||||
logger.info("VEO3: Operation started: {}", operationName);
|
||||
logger.info("VEO: Operation started: {} (model={})", operationName, modelName);
|
||||
|
||||
// ═══ ШАГ 2: Polling через fetchPredictOperation ══════════════════
|
||||
// ═══ ШАГ 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);
|
||||
location, projectId, location, modelName);
|
||||
|
||||
Map<String, Object> fetchBody = new HashMap<>();
|
||||
fetchBody.put("operationName", operationName);
|
||||
|
||||
int attempts = 0;
|
||||
int maxAttempts = 120; // 30 минут (120 × 15 сек)
|
||||
int maxAttempts = 120; // 30 минут
|
||||
|
||||
while (attempts < maxAttempts) {
|
||||
Thread.sleep(15000);
|
||||
Thread.sleep(15_000);
|
||||
attempts++;
|
||||
|
||||
String currentToken = getValidAccessToken();
|
||||
if (currentToken == null) {
|
||||
logger.warn("VEO3: No token on attempt {}, skipping...", attempts);
|
||||
logger.warn("VEO: No token on attempt {}", attempts);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -213,15 +257,16 @@ public class GeminiVideoGenerationService {
|
||||
.block(Duration.ofSeconds(60));
|
||||
|
||||
if (statusResponse == null) {
|
||||
logger.warn("VEO3: Null status on attempt {}", attempts);
|
||||
logger.warn("VEO: Null status on attempt {}", attempts);
|
||||
continue;
|
||||
}
|
||||
|
||||
String rawJson = objectMapper.writeValueAsString(statusResponse);
|
||||
logger.info("VEO3: [Attempt {}/{}] Raw: {}", attempts, maxAttempts, rawJson);
|
||||
logger.info("VEO: [Attempt {}/{}] model={} raw={}",
|
||||
attempts, maxAttempts, modelName, rawJson);
|
||||
|
||||
if (statusResponse.containsKey("error")) {
|
||||
logger.error("VEO3: Google returned error: {}",
|
||||
logger.error("VEO: Error in response: {}",
|
||||
objectMapper.writeValueAsString(statusResponse.get("error")));
|
||||
return null;
|
||||
}
|
||||
@@ -231,33 +276,33 @@ public class GeminiVideoGenerationService {
|
||||
|| "true".equalsIgnoreCase(String.valueOf(doneObj));
|
||||
|
||||
if (!isDone) {
|
||||
logger.info("VEO3: Still processing... {}/{}", attempts, maxAttempts);
|
||||
logger.info("VEO: Still processing {}/{}", attempts, maxAttempts);
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.info("VEO3: DONE on attempt {}! Extracting...", attempts);
|
||||
logger.info("VEO: DONE on attempt {}! Extracting video...", attempts);
|
||||
|
||||
// ═══ ШАГ 3: Извлечение видео ═════════════════════════════
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> responseObj =
|
||||
(Map<String, Object>) statusResponse.get("response");
|
||||
if (responseObj == null) {
|
||||
logger.warn("VEO3: No 'response' field, trying root...");
|
||||
logger.warn("VEO: No 'response' field — trying root...");
|
||||
responseObj = statusResponse;
|
||||
}
|
||||
|
||||
// Вариант 1 — base64
|
||||
String b64 = findFirstValue(responseObj, "bytesBase64Encoded");
|
||||
if (b64 != null && !b64.isEmpty()) {
|
||||
logger.info("VEO3: Found base64 (length={})", b64.length());
|
||||
return decodeBase64Video(b64);
|
||||
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("VEO3: Found video URI: {}", videoUri);
|
||||
logger.info("VEO: Found URI: {}", videoUri);
|
||||
return downloadFromGcs(videoUri, currentToken);
|
||||
}
|
||||
|
||||
@@ -267,87 +312,78 @@ public class GeminiVideoGenerationService {
|
||||
(List<Map<String, Object>>) responseObj.get("predictions");
|
||||
if (predictions != null && !predictions.isEmpty()) {
|
||||
Map<String, Object> first = predictions.get(0);
|
||||
logger.info("VEO3: Prediction keys: {}", first.keySet());
|
||||
logger.info("VEO: Prediction keys: {}", first.keySet());
|
||||
|
||||
b64 = (String) first.get("bytesBase64Encoded");
|
||||
if (b64 != null && !b64.isEmpty()) return decodeBase64Video(b64);
|
||||
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("VEO3: Video not found in response! Full dump: {}", rawJson);
|
||||
logger.error("VEO: Video not found! Full: {}", rawJson);
|
||||
return null;
|
||||
|
||||
} catch (WebClientResponseException e) {
|
||||
logger.error("VEO3: HTTP error on attempt {}: {} — {}",
|
||||
attempts, e.getStatusCode(), e.getResponseBodyAsString());
|
||||
logger.error("VEO: HTTP {} on attempt {}: {}",
|
||||
e.getStatusCode(), attempts, e.getResponseBodyAsString());
|
||||
if (e.getStatusCode().is4xxClientError()) {
|
||||
logger.error("VEO3: 4xx — stopping poll.");
|
||||
logger.error("VEO: 4xx — stopping poll for model={}", modelName);
|
||||
return null;
|
||||
}
|
||||
// При 5xx продолжаем
|
||||
// 5xx — продолжаем
|
||||
}
|
||||
}
|
||||
|
||||
logger.error("VEO3: TIMEOUT after {} minutes.", (maxAttempts * 15 / 60));
|
||||
logger.error("VEO: TIMEOUT after {} min for model={}", (maxAttempts * 15 / 60), modelName);
|
||||
return null;
|
||||
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
logger.error("VEO3: Thread interrupted");
|
||||
logger.error("VEO: Interrupted");
|
||||
return null;
|
||||
} catch (Exception e) {
|
||||
logger.error("VEO3: Fatal error: {}", e.getMessage(), e);
|
||||
logger.error("VEO: Fatal error (model={}): {}", modelName, e.getMessage(), e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] decodeBase64Video(String b64) {
|
||||
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);
|
||||
byte[] bytes = Base64.getDecoder().decode(clean);
|
||||
logger.info("VEO3: Decoded {} bytes from base64", bytes.length);
|
||||
logger.info("VEO: Decoded {} bytes", bytes.length);
|
||||
return bytes;
|
||||
} catch (Exception e) {
|
||||
logger.error("VEO3: Failed to decode base64: {}", e.getMessage());
|
||||
logger.error("VEO: base64 decode failed: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] downloadFromGcs(String uri, String accessToken) {
|
||||
try {
|
||||
String downloadUrl;
|
||||
if (uri.startsWith("gs://")) {
|
||||
downloadUrl = "https://storage.googleapis.com/" + uri.substring(5);
|
||||
logger.info("VEO3: GCS → HTTPS: {}", downloadUrl);
|
||||
} else if (uri.startsWith("https://")) {
|
||||
downloadUrl = uri;
|
||||
} else {
|
||||
logger.error("VEO3: Unknown URI format: {}", uri);
|
||||
return null;
|
||||
}
|
||||
String downloadUrl = uri.startsWith("gs://")
|
||||
? "https://storage.googleapis.com/" + uri.substring(5)
|
||||
: uri;
|
||||
logger.info("VEO: Downloading from: {}", downloadUrl);
|
||||
|
||||
byte[] videoBytes = webClient.get()
|
||||
byte[] bytes = webClient.get()
|
||||
.uri(URI.create(downloadUrl))
|
||||
.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken)
|
||||
.retrieve()
|
||||
.bodyToMono(byte[].class)
|
||||
.block(Duration.ofMinutes(5));
|
||||
|
||||
if (videoBytes != null && videoBytes.length > 0) {
|
||||
logger.info("VEO3: Downloaded {} bytes from GCS", videoBytes.length);
|
||||
return videoBytes;
|
||||
} else {
|
||||
logger.error("VEO3: Empty download from: {}", uri);
|
||||
return null;
|
||||
if (bytes != null && bytes.length > 0) {
|
||||
logger.info("VEO: Downloaded {} bytes from GCS", bytes.length);
|
||||
return bytes;
|
||||
}
|
||||
logger.error("VEO: Empty download from: {}", uri);
|
||||
return null;
|
||||
} catch (Exception e) {
|
||||
logger.error("VEO3: Download error from {}: {}", uri, e.getMessage());
|
||||
logger.error("VEO: GCS download error {}: {}", uri, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,15 +23,38 @@ import java.time.Duration;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.Semaphore;
|
||||
|
||||
/**
|
||||
* Сервис генерации изображений через Google Imagen 3.
|
||||
*
|
||||
* УЛУЧШЕНИЯ:
|
||||
* - Соотношение сторон зависит от платформы (4:5 для Instagram, 9:16 для TikTok/Reels)
|
||||
* - Негативный промпт на русском — единый язык с основным промптом
|
||||
* - Референс-изображение не перебивает основной промпт английскими инструкциями
|
||||
* - Метод generateImageForPlatform() для точного контроля формата
|
||||
*/
|
||||
@Service
|
||||
public class NanoBananaImageGenerationService implements ImageGenerationService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(NanoBananaImageGenerationService.class);
|
||||
private static final String VERTEX_API_TEMPLATE = "https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:predict";
|
||||
|
||||
private static final String VERTEX_API_TEMPLATE =
|
||||
"https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:predict";
|
||||
private static final String CREDENTIALS_FILE_PATH = "keys/google-key.json";
|
||||
|
||||
// Негативный промпт на русском — соответствует языку основных промптов
|
||||
private static final String NEGATIVE_PROMPT_RU =
|
||||
"размытое изображение, плохая анатомия, лишние конечности, шесть пальцев, три руки, " +
|
||||
"деформированное тело, неправильные пропорции, мультяшный стиль, 3D рендер, " +
|
||||
"искусственный вид, водяной знак, текстовые надписи на изображении, " +
|
||||
"нечёткий фон, пересвет, плохое качество, низкое разрешение";
|
||||
|
||||
// Один запрос в момент времени — Imagen 3 не любит параллельные запросы
|
||||
private final Semaphore semaphore = new Semaphore(1);
|
||||
private volatile long lastRequestTimestamp = 0;
|
||||
|
||||
// Минимальный интервал между запросами (15 сек — требование rate limit Imagen 3)
|
||||
private static final long MIN_REQUEST_INTERVAL_MS = 15_000;
|
||||
|
||||
private final WebClient webClient;
|
||||
|
||||
@Value("${google.cloud.project-id}")
|
||||
@@ -46,10 +69,11 @@ public class NanoBananaImageGenerationService implements ImageGenerationService
|
||||
public NanoBananaImageGenerationService() {
|
||||
HttpClient httpClient = HttpClient.create()
|
||||
.resolver(DefaultAddressResolverGroup.INSTANCE)
|
||||
.responseTimeout(Duration.ofMillis(180000));
|
||||
.responseTimeout(Duration.ofMillis(180_000));
|
||||
|
||||
ExchangeStrategies strategies = ExchangeStrategies.builder()
|
||||
.codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(30 * 1024 * 1024))
|
||||
.codecs(configurer -> configurer.defaultCodecs()
|
||||
.maxInMemorySize(30 * 1024 * 1024))
|
||||
.build();
|
||||
|
||||
this.webClient = WebClient.builder()
|
||||
@@ -58,11 +82,21 @@ public class NanoBananaImageGenerationService implements ImageGenerationService
|
||||
.build();
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// PUBLIC API
|
||||
// =====================================================================
|
||||
|
||||
/**
|
||||
* Базовая генерация — квадрат 1:1 (обратная совместимость).
|
||||
*/
|
||||
@Override
|
||||
public byte[] generateImage(String prompt) {
|
||||
return generateImageWithReference(prompt, (byte[]) null);
|
||||
return generateImageInternal(prompt, null, "1:1");
|
||||
}
|
||||
|
||||
/**
|
||||
* Генерация с логотипом — квадрат 1:1.
|
||||
*/
|
||||
public byte[] generateImage(String prompt, MultipartFile referenceLogo) {
|
||||
if (referenceLogo == null || referenceLogo.isEmpty()) {
|
||||
return generateImage(prompt);
|
||||
@@ -74,52 +108,169 @@ public class NanoBananaImageGenerationService implements ImageGenerationService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Генерация с референсным изображением — квадрат 1:1.
|
||||
* Используется для обратной совместимости со старым кодом.
|
||||
*/
|
||||
@Override
|
||||
public byte[] generateImageWithReference(String prompt, byte[] referenceImageBytes) {
|
||||
return generateImageInternal(prompt, referenceImageBytes, "1:1");
|
||||
}
|
||||
|
||||
/**
|
||||
* ОСНОВНОЙ МЕТОД для генерации постов.
|
||||
* Соотношение сторон выбирается автоматически по платформе:
|
||||
* - Instagram → 4:5 (портретный, оптимален для ленты)
|
||||
* - TikTok → 9:16 (вертикальный, полный экран)
|
||||
* - Telegram → 4:5 (портретный)
|
||||
* - LinkedIn → 4:5 (портретный)
|
||||
* - По умолчанию → 4:5
|
||||
*
|
||||
* @param prompt Промпт на русском языке
|
||||
* @param referenceImageBytes Байты логотипа/референса (может быть null)
|
||||
* @param platform Платформа: "instagram", "tiktok", "telegram", "linkedin"
|
||||
*/
|
||||
public byte[] generateImageForPlatform(String prompt, byte[] referenceImageBytes, String platform) {
|
||||
String aspectRatio = resolveAspectRatio(platform);
|
||||
logger.info("[Imagen3] Platform='{}' → aspectRatio='{}'", platform, aspectRatio);
|
||||
return generateImageInternal(prompt, referenceImageBytes, aspectRatio);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// CORE GENERATION
|
||||
// =====================================================================
|
||||
|
||||
private byte[] generateImageInternal(String prompt, byte[] referenceImageBytes, String aspectRatio) {
|
||||
if (projectId == null || projectId.trim().isEmpty()) {
|
||||
logger.error("[Imagen3] Project ID not configured!");
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
semaphore.acquire();
|
||||
|
||||
// Rate limit — минимум 15 сек между запросами
|
||||
long timeSinceLast = System.currentTimeMillis() - lastRequestTimestamp;
|
||||
if (timeSinceLast < 15000) {
|
||||
Thread.sleep(15000 - timeSinceLast);
|
||||
if (timeSinceLast < MIN_REQUEST_INTERVAL_MS) {
|
||||
long waitMs = MIN_REQUEST_INTERVAL_MS - timeSinceLast;
|
||||
logger.debug("[Imagen3] Rate limit wait: {}ms", waitMs);
|
||||
Thread.sleep(waitMs);
|
||||
}
|
||||
|
||||
String accessToken = getAccessTokenFromResources();
|
||||
String accessToken = getAccessToken();
|
||||
if (accessToken == null) {
|
||||
logger.error("[Imagen3] Failed to get access token");
|
||||
return null;
|
||||
}
|
||||
|
||||
String endpointUrl = String.format(VERTEX_API_TEMPLATE, location, projectId, location, model);
|
||||
String finalPrompt = prompt != null ? prompt : "High quality image";
|
||||
String endpointUrl = String.format(VERTEX_API_TEMPLATE,
|
||||
location, projectId, location, model);
|
||||
|
||||
String finalPrompt = (prompt != null && !prompt.isBlank()) ? prompt : "Качественное изображение";
|
||||
|
||||
logger.info("[Imagen3] Generating. AspectRatio={}, PromptLen={}",
|
||||
aspectRatio, finalPrompt.length());
|
||||
|
||||
try {
|
||||
Map<String, Object> requestBody = buildImagenRequestBody(finalPrompt, referenceImageBytes);
|
||||
Map<String, Object> response = executeVertexRequest(endpointUrl, accessToken, requestBody);
|
||||
Map<String, Object> requestBody =
|
||||
buildRequestBody(finalPrompt, referenceImageBytes, aspectRatio);
|
||||
Map<String, Object> response =
|
||||
executeRequest(endpointUrl, accessToken, requestBody);
|
||||
lastRequestTimestamp = System.currentTimeMillis();
|
||||
return extractImageFromImagenResponse(response);
|
||||
|
||||
byte[] result = extractImage(response);
|
||||
if (result != null) {
|
||||
logger.info("[Imagen3] ✅ Success. Bytes={}", result.length);
|
||||
} else {
|
||||
logger.warn("[Imagen3] Empty result from API");
|
||||
}
|
||||
return result;
|
||||
|
||||
} catch (WebClientResponseException.BadRequest badRequestEx) {
|
||||
logger.warn("[Imagen3] 400 BadRequest with reference — retrying without reference. Body: {}",
|
||||
badRequestEx.getResponseBodyAsString());
|
||||
|
||||
// Если референс вызвал ошибку — пробуем без него
|
||||
if (referenceImageBytes != null && referenceImageBytes.length > 0) {
|
||||
Map<String, Object> fallbackBody = buildImagenRequestBody(finalPrompt, null);
|
||||
Map<String, Object> fallbackResponse = executeVertexRequest(endpointUrl, accessToken, fallbackBody);
|
||||
Map<String, Object> fallbackBody =
|
||||
buildRequestBody(finalPrompt, null, aspectRatio);
|
||||
Map<String, Object> fallbackResponse =
|
||||
executeRequest(endpointUrl, accessToken, fallbackBody);
|
||||
lastRequestTimestamp = System.currentTimeMillis();
|
||||
return extractImageFromImagenResponse(fallbackResponse);
|
||||
return extractImage(fallbackResponse);
|
||||
}
|
||||
return null;
|
||||
|
||||
} catch (Exception ex) {
|
||||
logger.error("[Imagen3] Generation error: {}", ex.getMessage());
|
||||
return null;
|
||||
}
|
||||
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
logger.error("[Imagen3] Interrupted");
|
||||
return null;
|
||||
} catch (Exception e) {
|
||||
logger.error("[Imagen3] Fatal: {}", e.getMessage(), e);
|
||||
return null;
|
||||
} finally {
|
||||
semaphore.release();
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> executeVertexRequest(String endpointUrl, String accessToken, Map<String, Object> requestBody) {
|
||||
// =====================================================================
|
||||
// REQUEST BUILDING
|
||||
// =====================================================================
|
||||
|
||||
private Map<String, Object> buildRequestBody(String prompt,
|
||||
byte[] referenceImageBytes,
|
||||
String aspectRatio) {
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
List<Map<String, Object>> instances = new ArrayList<>();
|
||||
Map<String, Object> instance = new HashMap<>();
|
||||
|
||||
// Промпт — только на русском, без добавления английских инструкций
|
||||
instance.put("prompt", prompt);
|
||||
|
||||
// Референсное изображение (логотип клиента) — если передан
|
||||
if (referenceImageBytes != null && referenceImageBytes.length > 0) {
|
||||
String base64Image = Base64.getEncoder().encodeToString(referenceImageBytes);
|
||||
|
||||
Map<String, Object> imageBytesMap = new HashMap<>();
|
||||
imageBytesMap.put("bytesBase64Encoded", base64Image);
|
||||
|
||||
Map<String, Object> referenceImageParams = new HashMap<>();
|
||||
referenceImageParams.put("referenceImage", imageBytesMap);
|
||||
// STYLE — используем стиль лого, не SUBJECT (SUBJECT часто вызывает 400)
|
||||
referenceImageParams.put("referenceType", "STYLE");
|
||||
|
||||
instance.put("referenceImages", List.of(referenceImageParams));
|
||||
|
||||
logger.debug("[Imagen3] Using reference image ({} bytes)", referenceImageBytes.length);
|
||||
}
|
||||
|
||||
instances.add(instance);
|
||||
body.put("instances", instances);
|
||||
|
||||
// Параметры генерации
|
||||
Map<String, Object> parameters = new HashMap<>();
|
||||
parameters.put("sampleCount", 1);
|
||||
parameters.put("aspectRatio", aspectRatio);
|
||||
parameters.put("safetyFilterLevel", "block_some");
|
||||
parameters.put("personGeneration", "allow_adult");
|
||||
parameters.put("negativePrompt", NEGATIVE_PROMPT_RU);
|
||||
|
||||
body.put("parameters", parameters);
|
||||
return body;
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// HTTP
|
||||
// =====================================================================
|
||||
|
||||
private Map<String, Object> executeRequest(String endpointUrl,
|
||||
String accessToken,
|
||||
Map<String, Object> requestBody) {
|
||||
try {
|
||||
return webClient.post()
|
||||
.uri(URI.create(endpointUrl))
|
||||
@@ -128,25 +279,37 @@ public class NanoBananaImageGenerationService implements ImageGenerationService
|
||||
.bodyValue(requestBody)
|
||||
.retrieve()
|
||||
.bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {})
|
||||
.retryWhen(reactor.util.retry.Retry.backoff(10, Duration.ofSeconds(10))
|
||||
// Retry только на 429 (rate limit)
|
||||
.retryWhen(reactor.util.retry.Retry
|
||||
.backoff(5, Duration.ofSeconds(10))
|
||||
.maxBackoff(Duration.ofSeconds(60))
|
||||
.filter(t -> t instanceof WebClientResponseException &&
|
||||
((WebClientResponseException) t).getStatusCode().value() == 429))
|
||||
.block(Duration.ofMillis(180000));
|
||||
.filter(t -> t instanceof WebClientResponseException
|
||||
&& ((WebClientResponseException) t).getStatusCode().value() == 429)
|
||||
.doBeforeRetry(signal ->
|
||||
logger.warn("[Imagen3] 429 rate limit — retrying ({}/5)...",
|
||||
signal.totalRetries() + 1)))
|
||||
.block(Duration.ofMillis(180_000));
|
||||
} catch (WebClientResponseException e) {
|
||||
if (e.getStatusCode().value() == 400) {
|
||||
throw e;
|
||||
throw e; // Пробрасываем для обработки в вызывающем коде (retry без референса)
|
||||
}
|
||||
logger.error("[Imagen3] HTTP {}: {}", e.getStatusCode(), e.getResponseBodyAsString());
|
||||
return null;
|
||||
} catch (Exception e) {
|
||||
logger.error("[Imagen3] Request error: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String getAccessTokenFromResources() {
|
||||
// =====================================================================
|
||||
// AUTH
|
||||
// =====================================================================
|
||||
|
||||
private String getAccessToken() {
|
||||
try {
|
||||
ClassPathResource resource = new ClassPathResource(CREDENTIALS_FILE_PATH);
|
||||
if (!resource.exists()) {
|
||||
logger.error("[Imagen3] Credentials file not found: {}", CREDENTIALS_FILE_PATH);
|
||||
return null;
|
||||
}
|
||||
try (InputStream is = resource.getInputStream()) {
|
||||
@@ -156,64 +319,60 @@ public class NanoBananaImageGenerationService implements ImageGenerationService
|
||||
return credentials.getAccessToken().getTokenValue();
|
||||
}
|
||||
} catch (IOException e) {
|
||||
logger.error("[Imagen3] Failed to get access token: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> buildImagenRequestBody(String prompt, byte[] referenceImageBytes) {
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
List<Map<String, Object>> instances = new ArrayList<>();
|
||||
Map<String, Object> instance = new HashMap<>();
|
||||
|
||||
instance.put("prompt", prompt);
|
||||
|
||||
if (referenceImageBytes != null && referenceImageBytes.length > 0) {
|
||||
String base64Image = Base64.getEncoder().encodeToString(referenceImageBytes);
|
||||
Map<String, Object> referenceImageParams = new HashMap<>();
|
||||
Map<String, Object> imageBytesMap = new HashMap<>();
|
||||
|
||||
imageBytesMap.put("bytesBase64Encoded", base64Image);
|
||||
referenceImageParams.put("referenceImage", imageBytesMap);
|
||||
referenceImageParams.put("referenceType", "SUBJECT");
|
||||
|
||||
List<Map<String, Object>> refImagesList = new ArrayList<>();
|
||||
refImagesList.add(referenceImageParams);
|
||||
|
||||
instance.put("referenceImages", refImagesList);
|
||||
instance.put("prompt", prompt + ". CRITICAL: Preserve the text, letters, and typography of the provided logo EXACTLY as they appear. Do not invent new text, do not distort letters. Integrate naturally.");
|
||||
}
|
||||
|
||||
instances.add(instance);
|
||||
body.put("instances", instances);
|
||||
|
||||
Map<String, Object> parameters = new HashMap<>();
|
||||
parameters.put("sampleCount", 1);
|
||||
parameters.put("aspectRatio", "1:1");
|
||||
parameters.put("safetyFilterLevel", "block_some");
|
||||
parameters.put("personGeneration", "allow_adult");
|
||||
parameters.put("negativePrompt", "gibberish text, alien language, wrong spelling, distorted letters, bad anatomy, 6 fingers, 3 arms, mutated, distorted, extra limbs, bad proportions, unnatural bodies, nsfw, watermark");
|
||||
|
||||
body.put("parameters", parameters);
|
||||
return body;
|
||||
}
|
||||
// =====================================================================
|
||||
// RESPONSE PARSING
|
||||
// =====================================================================
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private byte[] extractImageFromImagenResponse(Map<String, Object> response) {
|
||||
private byte[] extractImage(Map<String, Object> response) {
|
||||
if (response == null) return null;
|
||||
try {
|
||||
List<Map<String, Object>> predictions = (List<Map<String, Object>>) response.get("predictions");
|
||||
List<Map<String, Object>> predictions =
|
||||
(List<Map<String, Object>>) response.get("predictions");
|
||||
if (predictions != null && !predictions.isEmpty()) {
|
||||
Map<String, Object> firstPrediction = predictions.get(0);
|
||||
String base64Image = (String) firstPrediction.get("bytesBase64Encoded");
|
||||
if (base64Image == null) {
|
||||
base64Image = (String) firstPrediction.get("b64");
|
||||
Map<String, Object> first = predictions.get(0);
|
||||
// Imagen 3 возвращает bytesBase64Encoded
|
||||
String base64 = (String) first.get("bytesBase64Encoded");
|
||||
if (base64 == null) {
|
||||
// Fallback — старый ключ
|
||||
base64 = (String) first.get("b64");
|
||||
}
|
||||
if (base64Image != null) {
|
||||
return java.util.Base64.getDecoder().decode(base64Image);
|
||||
if (base64 != null && !base64.isBlank()) {
|
||||
return Base64.getDecoder().decode(base64);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.error("[Imagen3] Failed to extract image from response: {}", e.getMessage());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// UTILS
|
||||
// =====================================================================
|
||||
|
||||
/**
|
||||
* Выбирает правильное соотношение сторон под платформу.
|
||||
*
|
||||
* Imagen 3 поддерживает: 1:1, 4:3, 3:4, 16:9, 9:16
|
||||
*
|
||||
* Instagram лента → 4:5 НЕ поддерживается Imagen 3, поэтому используем 3:4 (близко к 4:5)
|
||||
* TikTok / Reels → 9:16 (вертикальный полный экран)
|
||||
*/
|
||||
private String resolveAspectRatio(String platform) {
|
||||
if (platform == null) return "3:4";
|
||||
return switch (platform.toLowerCase().trim()) {
|
||||
case "tiktok" -> "9:16"; // вертикальный для TikTok Reels
|
||||
case "instagram" -> "3:4"; // портретный для Instagram ленты (ближайший к 4:5)
|
||||
case "telegram" -> "3:4"; // портретный
|
||||
case "linkedin" -> "4:3"; // горизонтальный для LinkedIn
|
||||
case "youtube" -> "16:9"; // горизонтальный для YouTube
|
||||
default -> "3:4"; // по умолчанию портретный
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user