fix
This commit is contained in:
@@ -614,7 +614,7 @@ public class MarketingAnalysisV3Controller {
|
|||||||
if (title.length() > 80) {
|
if (title.length() > 80) {
|
||||||
title = title.substring(0, 77) + "...";
|
title = title.substring(0, 77) + "...";
|
||||||
}
|
}
|
||||||
|
|
||||||
response.setAnalysisTitle(title);
|
response.setAnalysisTitle(title);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,12 +25,23 @@ import java.time.Duration;
|
|||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Сервис генерации видео через Google Veo 3.
|
* Сервис генерации видео через Google Veo 3 (с fallback на Veo 2).
|
||||||
*
|
*
|
||||||
* КЛЮЧЕВЫЕ ОСОБЕННОСТИ Veo 3:
|
* ══ ЗВУК В VEO 3 ══
|
||||||
* - Нативный звук: музыка, диалоги, фоновые звуки — без доп. настроек
|
* Veo 3 добавляет звук АВТОМАТИЧЕСКИ если в промпте описан звук.
|
||||||
* - enhancePrompt = false — ОБЯЗАТЕЛЬНО, иначе Google переводит промпт на английский
|
* Параметр 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
|
@Service
|
||||||
public class GeminiVideoGenerationService {
|
public class GeminiVideoGenerationService {
|
||||||
@@ -38,6 +49,9 @@ public class GeminiVideoGenerationService {
|
|||||||
private static final Logger logger = LoggerFactory.getLogger(GeminiVideoGenerationService.class);
|
private static final Logger logger = LoggerFactory.getLogger(GeminiVideoGenerationService.class);
|
||||||
private static final String CREDENTIALS_FILE_PATH = "keys/google-key.json";
|
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 final WebClient webClient;
|
||||||
private GoogleCredentials credentials;
|
private GoogleCredentials credentials;
|
||||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
@@ -76,32 +90,32 @@ public class GeminiVideoGenerationService {
|
|||||||
this.credentials = GoogleCredentials.fromStream(is)
|
this.credentials = GoogleCredentials.fromStream(is)
|
||||||
.createScoped(Collections.singletonList(
|
.createScoped(Collections.singletonList(
|
||||||
"https://www.googleapis.com/auth/cloud-platform"));
|
"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);
|
projectId, location, model);
|
||||||
}
|
}
|
||||||
} else {
|
} 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) {
|
} catch (IOException e) {
|
||||||
logger.error("VEO3: Error reading credentials: {}", e.getMessage());
|
logger.error("VEO: Error reading credentials: {}", e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private String getValidAccessToken() {
|
private String getValidAccessToken() {
|
||||||
if (this.credentials == null) {
|
if (this.credentials == null) {
|
||||||
logger.error("VEO3: Credentials not initialized!");
|
logger.error("VEO: Credentials not initialized!");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
this.credentials.refreshIfExpired();
|
this.credentials.refreshIfExpired();
|
||||||
String token = this.credentials.getAccessToken().getTokenValue();
|
String token = this.credentials.getAccessToken().getTokenValue();
|
||||||
if (token == null || token.isEmpty()) {
|
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 null;
|
||||||
}
|
}
|
||||||
return token;
|
return token;
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
logger.error("VEO3: Token refresh error: {}", e.getMessage());
|
logger.error("VEO: Token refresh error: {}", e.getMessage());
|
||||||
return null;
|
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) {
|
public byte[] generateVideo(String prompt) {
|
||||||
if (projectId == null || projectId.trim().isEmpty()) {
|
if (projectId == null || projectId.trim().isEmpty()) {
|
||||||
logger.error("VEO3: Project ID is not configured!");
|
logger.error("VEO: Project ID not configured!");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
String accessToken = getValidAccessToken();
|
String accessToken = getValidAccessToken();
|
||||||
if (accessToken == null) return null;
|
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 {
|
try {
|
||||||
// ═══ ШАГ 1: Запуск генерации через predictLongRunning ════════════
|
// ═══ ШАГ 1: Запуск генерации ════════════════════════════════════
|
||||||
String generateEndpoint = String.format(
|
String generateEndpoint = String.format(
|
||||||
"https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:predictLongRunning",
|
"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<>();
|
Map<String, Object> instance = new HashMap<>();
|
||||||
instance.put("prompt", prompt);
|
instance.put("prompt", prompt);
|
||||||
@@ -134,21 +175,25 @@ public class GeminiVideoGenerationService {
|
|||||||
Map<String, Object> parameters = new HashMap<>();
|
Map<String, Object> parameters = new HashMap<>();
|
||||||
parameters.put("aspectRatio", "9:16");
|
parameters.put("aspectRatio", "9:16");
|
||||||
parameters.put("sampleCount", 1);
|
parameters.put("sampleCount", 1);
|
||||||
// КРИТИЧНО: false — не давать Google переводить промпт на английский
|
// КРИТИЧНО: false — иначе Google переводит промпт на английский!
|
||||||
parameters.put("enhancePrompt", false);
|
parameters.put("enhancePrompt", false);
|
||||||
// Негативный промпт тоже на русском
|
// Негативный промпт на русском — единый язык с основным промптом
|
||||||
parameters.put("negativePrompt",
|
parameters.put("negativePrompt",
|
||||||
"размытое изображение, плохая анатомия, лишние конечности, деформированные лица, " +
|
"размытое изображение, плохая анатомия, лишние конечности, " +
|
||||||
"мультяшный стиль, 3D рендер, искусственный вид, субтитры на английском, " +
|
"деформированные лица, мультяшный стиль, 3D рендер, " +
|
||||||
"водяной знак, нереалистичное движение, некрасивые руки, лишние пальцы");
|
"искусственный неестественный вид, субтитры на английском, " +
|
||||||
|
"водяной знак, нереалистичное движение, некрасивые руки, лишние пальцы, " +
|
||||||
|
"дёрганые движения, низкое качество, пиксели");
|
||||||
|
// ВАЖНО: НЕ добавляем generateAudio:true — это вызывает 400 через Vertex AI REST.
|
||||||
|
// Звук в Veo 3 активируется через описание в промпте (работает через Vertex AI).
|
||||||
|
|
||||||
Map<String, Object> requestBody = new HashMap<>();
|
Map<String, Object> requestBody = new HashMap<>();
|
||||||
requestBody.put("instances", Collections.singletonList(instance));
|
requestBody.put("instances", Collections.singletonList(instance));
|
||||||
requestBody.put("parameters", parameters);
|
requestBody.put("parameters", parameters);
|
||||||
|
|
||||||
logger.info("VEO3: Starting generation. Model={}, Endpoint={}", model, generateEndpoint);
|
logger.info("VEO: Starting. Model={}", modelName);
|
||||||
logger.info("VEO3: Prompt (preview 300): '{}'",
|
logger.info("VEO: Prompt preview: '{}'",
|
||||||
prompt.length() > 300 ? prompt.substring(0, 300) + "..." : prompt);
|
prompt.length() > 250 ? prompt.substring(0, 250) + "..." : prompt);
|
||||||
|
|
||||||
Map<String, Object> initResponse;
|
Map<String, Object> initResponse;
|
||||||
try {
|
try {
|
||||||
@@ -159,46 +204,45 @@ public class GeminiVideoGenerationService {
|
|||||||
.bodyValue(requestBody)
|
.bodyValue(requestBody)
|
||||||
.retrieve()
|
.retrieve()
|
||||||
.bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {})
|
.bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {})
|
||||||
.block(Duration.ofSeconds(120));
|
.block(Duration.ofSeconds(60));
|
||||||
} catch (WebClientResponseException e) {
|
} catch (WebClientResponseException e) {
|
||||||
logger.error("VEO3: HTTP {} on initial request. Body: {}",
|
logger.error("VEO: HTTP {} on initial request (model={}). Body: {}",
|
||||||
e.getStatusCode(), e.getResponseBodyAsString());
|
e.getStatusCode(), modelName, e.getResponseBodyAsString());
|
||||||
|
// 404 или 403 — модель недоступна в проекте, возвращаем null для fallback
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (initResponse == null) {
|
if (initResponse == null) {
|
||||||
logger.error("VEO3: Initial response is null!");
|
logger.error("VEO: Null initial response for model={}", modelName);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("VEO3: Initial response: {}", objectMapper.writeValueAsString(initResponse));
|
|
||||||
|
|
||||||
String operationName = (String) initResponse.get("name");
|
String operationName = (String) initResponse.get("name");
|
||||||
if (operationName == null || operationName.isEmpty()) {
|
if (operationName == null || operationName.isEmpty()) {
|
||||||
logger.error("VEO3: No 'name' field! Full response: {}",
|
logger.error("VEO: No 'name' in response! model={} response={}",
|
||||||
objectMapper.writeValueAsString(initResponse));
|
modelName, objectMapper.writeValueAsString(initResponse));
|
||||||
return null;
|
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(
|
String fetchEndpoint = String.format(
|
||||||
"https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:fetchPredictOperation",
|
"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<>();
|
Map<String, Object> fetchBody = new HashMap<>();
|
||||||
fetchBody.put("operationName", operationName);
|
fetchBody.put("operationName", operationName);
|
||||||
|
|
||||||
int attempts = 0;
|
int attempts = 0;
|
||||||
int maxAttempts = 120; // 30 минут (120 × 15 сек)
|
int maxAttempts = 120; // 30 минут
|
||||||
|
|
||||||
while (attempts < maxAttempts) {
|
while (attempts < maxAttempts) {
|
||||||
Thread.sleep(15000);
|
Thread.sleep(15_000);
|
||||||
attempts++;
|
attempts++;
|
||||||
|
|
||||||
String currentToken = getValidAccessToken();
|
String currentToken = getValidAccessToken();
|
||||||
if (currentToken == null) {
|
if (currentToken == null) {
|
||||||
logger.warn("VEO3: No token on attempt {}, skipping...", attempts);
|
logger.warn("VEO: No token on attempt {}", attempts);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,15 +257,16 @@ public class GeminiVideoGenerationService {
|
|||||||
.block(Duration.ofSeconds(60));
|
.block(Duration.ofSeconds(60));
|
||||||
|
|
||||||
if (statusResponse == null) {
|
if (statusResponse == null) {
|
||||||
logger.warn("VEO3: Null status on attempt {}", attempts);
|
logger.warn("VEO: Null status on attempt {}", attempts);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
String rawJson = objectMapper.writeValueAsString(statusResponse);
|
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")) {
|
if (statusResponse.containsKey("error")) {
|
||||||
logger.error("VEO3: Google returned error: {}",
|
logger.error("VEO: Error in response: {}",
|
||||||
objectMapper.writeValueAsString(statusResponse.get("error")));
|
objectMapper.writeValueAsString(statusResponse.get("error")));
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -231,33 +276,33 @@ public class GeminiVideoGenerationService {
|
|||||||
|| "true".equalsIgnoreCase(String.valueOf(doneObj));
|
|| "true".equalsIgnoreCase(String.valueOf(doneObj));
|
||||||
|
|
||||||
if (!isDone) {
|
if (!isDone) {
|
||||||
logger.info("VEO3: Still processing... {}/{}", attempts, maxAttempts);
|
logger.info("VEO: Still processing {}/{}", attempts, maxAttempts);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("VEO3: DONE on attempt {}! Extracting...", attempts);
|
logger.info("VEO: DONE on attempt {}! Extracting video...", attempts);
|
||||||
|
|
||||||
// ═══ ШАГ 3: Извлечение видео ═════════════════════════════
|
// ═══ ШАГ 3: Извлечение видео ═════════════════════════════
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
Map<String, Object> responseObj =
|
Map<String, Object> responseObj =
|
||||||
(Map<String, Object>) statusResponse.get("response");
|
(Map<String, Object>) statusResponse.get("response");
|
||||||
if (responseObj == null) {
|
if (responseObj == null) {
|
||||||
logger.warn("VEO3: No 'response' field, trying root...");
|
logger.warn("VEO: No 'response' field — trying root...");
|
||||||
responseObj = statusResponse;
|
responseObj = statusResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Вариант 1 — base64
|
// Вариант 1 — base64
|
||||||
String b64 = findFirstValue(responseObj, "bytesBase64Encoded");
|
String b64 = findFirstValue(responseObj, "bytesBase64Encoded");
|
||||||
if (b64 != null && !b64.isEmpty()) {
|
if (b64 != null && !b64.isEmpty()) {
|
||||||
logger.info("VEO3: Found base64 (length={})", b64.length());
|
logger.info("VEO: Found base64 (len={})", b64.length());
|
||||||
return decodeBase64Video(b64);
|
return decodeBase64(b64);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Вариант 2 — GCS URI
|
// Вариант 2 — GCS URI
|
||||||
String videoUri = findFirstValue(responseObj, "videoUri");
|
String videoUri = findFirstValue(responseObj, "videoUri");
|
||||||
if (videoUri == null) videoUri = findFirstValue(responseObj, "uri");
|
if (videoUri == null) videoUri = findFirstValue(responseObj, "uri");
|
||||||
if (videoUri != null && !videoUri.isEmpty()) {
|
if (videoUri != null && !videoUri.isEmpty()) {
|
||||||
logger.info("VEO3: Found video URI: {}", videoUri);
|
logger.info("VEO: Found URI: {}", videoUri);
|
||||||
return downloadFromGcs(videoUri, currentToken);
|
return downloadFromGcs(videoUri, currentToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,87 +312,78 @@ public class GeminiVideoGenerationService {
|
|||||||
(List<Map<String, Object>>) responseObj.get("predictions");
|
(List<Map<String, Object>>) responseObj.get("predictions");
|
||||||
if (predictions != null && !predictions.isEmpty()) {
|
if (predictions != null && !predictions.isEmpty()) {
|
||||||
Map<String, Object> first = predictions.get(0);
|
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");
|
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");
|
String uri = (String) first.get("videoUri");
|
||||||
if (uri == null) uri = (String) first.get("uri");
|
if (uri == null) uri = (String) first.get("uri");
|
||||||
if (uri != null && !uri.isEmpty()) return downloadFromGcs(uri, currentToken);
|
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;
|
return null;
|
||||||
|
|
||||||
} catch (WebClientResponseException e) {
|
} catch (WebClientResponseException e) {
|
||||||
logger.error("VEO3: HTTP error on attempt {}: {} — {}",
|
logger.error("VEO: HTTP {} on attempt {}: {}",
|
||||||
attempts, e.getStatusCode(), e.getResponseBodyAsString());
|
e.getStatusCode(), attempts, e.getResponseBodyAsString());
|
||||||
if (e.getStatusCode().is4xxClientError()) {
|
if (e.getStatusCode().is4xxClientError()) {
|
||||||
logger.error("VEO3: 4xx — stopping poll.");
|
logger.error("VEO: 4xx — stopping poll for model={}", modelName);
|
||||||
return null;
|
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;
|
return null;
|
||||||
|
|
||||||
} catch (InterruptedException e) {
|
} catch (InterruptedException e) {
|
||||||
Thread.currentThread().interrupt();
|
Thread.currentThread().interrupt();
|
||||||
logger.error("VEO3: Thread interrupted");
|
logger.error("VEO: Interrupted");
|
||||||
return null;
|
return null;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
logger.error("VEO3: Fatal error: {}", e.getMessage(), e);
|
logger.error("VEO: Fatal error (model={}): {}", modelName, e.getMessage(), e);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private byte[] decodeBase64Video(String b64) {
|
private byte[] decodeBase64(String b64) {
|
||||||
try {
|
try {
|
||||||
String clean = b64.replaceAll("\\s", "");
|
String clean = b64.replaceAll("\\s", "");
|
||||||
if (clean.contains("base64,")) {
|
if (clean.contains("base64,")) clean = clean.substring(clean.indexOf("base64,") + 7);
|
||||||
clean = clean.substring(clean.indexOf("base64,") + 7);
|
|
||||||
}
|
|
||||||
byte[] bytes = Base64.getDecoder().decode(clean);
|
byte[] bytes = Base64.getDecoder().decode(clean);
|
||||||
logger.info("VEO3: Decoded {} bytes from base64", bytes.length);
|
logger.info("VEO: Decoded {} bytes", bytes.length);
|
||||||
return bytes;
|
return bytes;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
logger.error("VEO3: Failed to decode base64: {}", e.getMessage());
|
logger.error("VEO: base64 decode failed: {}", e.getMessage());
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private byte[] downloadFromGcs(String uri, String accessToken) {
|
private byte[] downloadFromGcs(String uri, String accessToken) {
|
||||||
try {
|
try {
|
||||||
String downloadUrl;
|
String downloadUrl = uri.startsWith("gs://")
|
||||||
if (uri.startsWith("gs://")) {
|
? "https://storage.googleapis.com/" + uri.substring(5)
|
||||||
downloadUrl = "https://storage.googleapis.com/" + uri.substring(5);
|
: uri;
|
||||||
logger.info("VEO3: GCS → HTTPS: {}", downloadUrl);
|
logger.info("VEO: Downloading from: {}", downloadUrl);
|
||||||
} else if (uri.startsWith("https://")) {
|
|
||||||
downloadUrl = uri;
|
|
||||||
} else {
|
|
||||||
logger.error("VEO3: Unknown URI format: {}", uri);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
byte[] videoBytes = webClient.get()
|
byte[] bytes = webClient.get()
|
||||||
.uri(URI.create(downloadUrl))
|
.uri(URI.create(downloadUrl))
|
||||||
.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken)
|
.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken)
|
||||||
.retrieve()
|
.retrieve()
|
||||||
.bodyToMono(byte[].class)
|
.bodyToMono(byte[].class)
|
||||||
.block(Duration.ofMinutes(5));
|
.block(Duration.ofMinutes(5));
|
||||||
|
|
||||||
if (videoBytes != null && videoBytes.length > 0) {
|
if (bytes != null && bytes.length > 0) {
|
||||||
logger.info("VEO3: Downloaded {} bytes from GCS", videoBytes.length);
|
logger.info("VEO: Downloaded {} bytes from GCS", bytes.length);
|
||||||
return videoBytes;
|
return bytes;
|
||||||
} else {
|
|
||||||
logger.error("VEO3: Empty download from: {}", uri);
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
logger.error("VEO: Empty download from: {}", uri);
|
||||||
|
return null;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
logger.error("VEO3: Download error from {}: {}", uri, e.getMessage());
|
logger.error("VEO: GCS download error {}: {}", uri, e.getMessage());
|
||||||
return null;
|
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.*;
|
||||||
import java.util.concurrent.Semaphore;
|
import java.util.concurrent.Semaphore;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сервис генерации изображений через Google Imagen 3.
|
||||||
|
*
|
||||||
|
* УЛУЧШЕНИЯ:
|
||||||
|
* - Соотношение сторон зависит от платформы (4:5 для Instagram, 9:16 для TikTok/Reels)
|
||||||
|
* - Негативный промпт на русском — единый язык с основным промптом
|
||||||
|
* - Референс-изображение не перебивает основной промпт английскими инструкциями
|
||||||
|
* - Метод generateImageForPlatform() для точного контроля формата
|
||||||
|
*/
|
||||||
@Service
|
@Service
|
||||||
public class NanoBananaImageGenerationService implements ImageGenerationService {
|
public class NanoBananaImageGenerationService implements ImageGenerationService {
|
||||||
|
|
||||||
private static final Logger logger = LoggerFactory.getLogger(NanoBananaImageGenerationService.class);
|
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 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 final Semaphore semaphore = new Semaphore(1);
|
||||||
private volatile long lastRequestTimestamp = 0;
|
private volatile long lastRequestTimestamp = 0;
|
||||||
|
|
||||||
|
// Минимальный интервал между запросами (15 сек — требование rate limit Imagen 3)
|
||||||
|
private static final long MIN_REQUEST_INTERVAL_MS = 15_000;
|
||||||
|
|
||||||
private final WebClient webClient;
|
private final WebClient webClient;
|
||||||
|
|
||||||
@Value("${google.cloud.project-id}")
|
@Value("${google.cloud.project-id}")
|
||||||
@@ -46,10 +69,11 @@ public class NanoBananaImageGenerationService implements ImageGenerationService
|
|||||||
public NanoBananaImageGenerationService() {
|
public NanoBananaImageGenerationService() {
|
||||||
HttpClient httpClient = HttpClient.create()
|
HttpClient httpClient = HttpClient.create()
|
||||||
.resolver(DefaultAddressResolverGroup.INSTANCE)
|
.resolver(DefaultAddressResolverGroup.INSTANCE)
|
||||||
.responseTimeout(Duration.ofMillis(180000));
|
.responseTimeout(Duration.ofMillis(180_000));
|
||||||
|
|
||||||
ExchangeStrategies strategies = ExchangeStrategies.builder()
|
ExchangeStrategies strategies = ExchangeStrategies.builder()
|
||||||
.codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(30 * 1024 * 1024))
|
.codecs(configurer -> configurer.defaultCodecs()
|
||||||
|
.maxInMemorySize(30 * 1024 * 1024))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
this.webClient = WebClient.builder()
|
this.webClient = WebClient.builder()
|
||||||
@@ -58,11 +82,21 @@ public class NanoBananaImageGenerationService implements ImageGenerationService
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// PUBLIC API
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Базовая генерация — квадрат 1:1 (обратная совместимость).
|
||||||
|
*/
|
||||||
@Override
|
@Override
|
||||||
public byte[] generateImage(String prompt) {
|
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) {
|
public byte[] generateImage(String prompt, MultipartFile referenceLogo) {
|
||||||
if (referenceLogo == null || referenceLogo.isEmpty()) {
|
if (referenceLogo == null || referenceLogo.isEmpty()) {
|
||||||
return generateImage(prompt);
|
return generateImage(prompt);
|
||||||
@@ -74,52 +108,169 @@ public class NanoBananaImageGenerationService implements ImageGenerationService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Генерация с референсным изображением — квадрат 1:1.
|
||||||
|
* Используется для обратной совместимости со старым кодом.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
public byte[] generateImageWithReference(String prompt, byte[] referenceImageBytes) {
|
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()) {
|
if (projectId == null || projectId.trim().isEmpty()) {
|
||||||
|
logger.error("[Imagen3] Project ID not configured!");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
semaphore.acquire();
|
semaphore.acquire();
|
||||||
|
|
||||||
|
// Rate limit — минимум 15 сек между запросами
|
||||||
long timeSinceLast = System.currentTimeMillis() - lastRequestTimestamp;
|
long timeSinceLast = System.currentTimeMillis() - lastRequestTimestamp;
|
||||||
if (timeSinceLast < 15000) {
|
if (timeSinceLast < MIN_REQUEST_INTERVAL_MS) {
|
||||||
Thread.sleep(15000 - timeSinceLast);
|
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) {
|
if (accessToken == null) {
|
||||||
|
logger.error("[Imagen3] Failed to get access token");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
String endpointUrl = String.format(VERTEX_API_TEMPLATE, location, projectId, location, model);
|
String endpointUrl = String.format(VERTEX_API_TEMPLATE,
|
||||||
String finalPrompt = prompt != null ? prompt : "High quality image";
|
location, projectId, location, model);
|
||||||
|
|
||||||
|
String finalPrompt = (prompt != null && !prompt.isBlank()) ? prompt : "Качественное изображение";
|
||||||
|
|
||||||
|
logger.info("[Imagen3] Generating. AspectRatio={}, PromptLen={}",
|
||||||
|
aspectRatio, finalPrompt.length());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Map<String, Object> requestBody = buildImagenRequestBody(finalPrompt, referenceImageBytes);
|
Map<String, Object> requestBody =
|
||||||
Map<String, Object> response = executeVertexRequest(endpointUrl, accessToken, requestBody);
|
buildRequestBody(finalPrompt, referenceImageBytes, aspectRatio);
|
||||||
|
Map<String, Object> response =
|
||||||
|
executeRequest(endpointUrl, accessToken, requestBody);
|
||||||
lastRequestTimestamp = System.currentTimeMillis();
|
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) {
|
} catch (WebClientResponseException.BadRequest badRequestEx) {
|
||||||
|
logger.warn("[Imagen3] 400 BadRequest with reference — retrying without reference. Body: {}",
|
||||||
|
badRequestEx.getResponseBodyAsString());
|
||||||
|
|
||||||
|
// Если референс вызвал ошибку — пробуем без него
|
||||||
if (referenceImageBytes != null && referenceImageBytes.length > 0) {
|
if (referenceImageBytes != null && referenceImageBytes.length > 0) {
|
||||||
Map<String, Object> fallbackBody = buildImagenRequestBody(finalPrompt, null);
|
Map<String, Object> fallbackBody =
|
||||||
Map<String, Object> fallbackResponse = executeVertexRequest(endpointUrl, accessToken, fallbackBody);
|
buildRequestBody(finalPrompt, null, aspectRatio);
|
||||||
|
Map<String, Object> fallbackResponse =
|
||||||
|
executeRequest(endpointUrl, accessToken, fallbackBody);
|
||||||
lastRequestTimestamp = System.currentTimeMillis();
|
lastRequestTimestamp = System.currentTimeMillis();
|
||||||
return extractImageFromImagenResponse(fallbackResponse);
|
return extractImage(fallbackResponse);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
|
logger.error("[Imagen3] Generation error: {}", ex.getMessage());
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
logger.error("[Imagen3] Interrupted");
|
||||||
|
return null;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
logger.error("[Imagen3] Fatal: {}", e.getMessage(), e);
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
semaphore.release();
|
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 {
|
try {
|
||||||
return webClient.post()
|
return webClient.post()
|
||||||
.uri(URI.create(endpointUrl))
|
.uri(URI.create(endpointUrl))
|
||||||
@@ -128,25 +279,37 @@ public class NanoBananaImageGenerationService implements ImageGenerationService
|
|||||||
.bodyValue(requestBody)
|
.bodyValue(requestBody)
|
||||||
.retrieve()
|
.retrieve()
|
||||||
.bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {})
|
.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))
|
.maxBackoff(Duration.ofSeconds(60))
|
||||||
.filter(t -> t instanceof WebClientResponseException &&
|
.filter(t -> t instanceof WebClientResponseException
|
||||||
((WebClientResponseException) t).getStatusCode().value() == 429))
|
&& ((WebClientResponseException) t).getStatusCode().value() == 429)
|
||||||
.block(Duration.ofMillis(180000));
|
.doBeforeRetry(signal ->
|
||||||
|
logger.warn("[Imagen3] 429 rate limit — retrying ({}/5)...",
|
||||||
|
signal.totalRetries() + 1)))
|
||||||
|
.block(Duration.ofMillis(180_000));
|
||||||
} catch (WebClientResponseException e) {
|
} catch (WebClientResponseException e) {
|
||||||
if (e.getStatusCode().value() == 400) {
|
if (e.getStatusCode().value() == 400) {
|
||||||
throw e;
|
throw e; // Пробрасываем для обработки в вызывающем коде (retry без референса)
|
||||||
}
|
}
|
||||||
|
logger.error("[Imagen3] HTTP {}: {}", e.getStatusCode(), e.getResponseBodyAsString());
|
||||||
return null;
|
return null;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
logger.error("[Imagen3] Request error: {}", e.getMessage());
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private String getAccessTokenFromResources() {
|
// =====================================================================
|
||||||
|
// AUTH
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
private String getAccessToken() {
|
||||||
try {
|
try {
|
||||||
ClassPathResource resource = new ClassPathResource(CREDENTIALS_FILE_PATH);
|
ClassPathResource resource = new ClassPathResource(CREDENTIALS_FILE_PATH);
|
||||||
if (!resource.exists()) {
|
if (!resource.exists()) {
|
||||||
|
logger.error("[Imagen3] Credentials file not found: {}", CREDENTIALS_FILE_PATH);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
try (InputStream is = resource.getInputStream()) {
|
try (InputStream is = resource.getInputStream()) {
|
||||||
@@ -156,64 +319,60 @@ public class NanoBananaImageGenerationService implements ImageGenerationService
|
|||||||
return credentials.getAccessToken().getTokenValue();
|
return credentials.getAccessToken().getTokenValue();
|
||||||
}
|
}
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
|
logger.error("[Imagen3] Failed to get access token: {}", e.getMessage());
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<String, Object> buildImagenRequestBody(String prompt, byte[] referenceImageBytes) {
|
// =====================================================================
|
||||||
Map<String, Object> body = new HashMap<>();
|
// RESPONSE PARSING
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
private byte[] extractImageFromImagenResponse(Map<String, Object> response) {
|
private byte[] extractImage(Map<String, Object> response) {
|
||||||
if (response == null) return null;
|
if (response == null) return null;
|
||||||
try {
|
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()) {
|
if (predictions != null && !predictions.isEmpty()) {
|
||||||
Map<String, Object> firstPrediction = predictions.get(0);
|
Map<String, Object> first = predictions.get(0);
|
||||||
String base64Image = (String) firstPrediction.get("bytesBase64Encoded");
|
// Imagen 3 возвращает bytesBase64Encoded
|
||||||
if (base64Image == null) {
|
String base64 = (String) first.get("bytesBase64Encoded");
|
||||||
base64Image = (String) firstPrediction.get("b64");
|
if (base64 == null) {
|
||||||
|
// Fallback — старый ключ
|
||||||
|
base64 = (String) first.get("b64");
|
||||||
}
|
}
|
||||||
if (base64Image != null) {
|
if (base64 != null && !base64.isBlank()) {
|
||||||
return java.util.Base64.getDecoder().decode(base64Image);
|
return Base64.getDecoder().decode(base64);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
logger.error("[Imagen3] Failed to extract image from response: {}", e.getMessage());
|
||||||
}
|
}
|
||||||
return null;
|
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