feat(back-end): admin home edit image page
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
package com.printcalculator.controller;
|
||||
|
||||
import com.printcalculator.dto.PublicMediaUsageDto;
|
||||
import com.printcalculator.service.media.PublicMediaQueryService;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/public/media")
|
||||
@Transactional(readOnly = true)
|
||||
public class PublicMediaController {
|
||||
|
||||
private final PublicMediaQueryService publicMediaQueryService;
|
||||
|
||||
public PublicMediaController(PublicMediaQueryService publicMediaQueryService) {
|
||||
this.publicMediaQueryService = publicMediaQueryService;
|
||||
}
|
||||
|
||||
@GetMapping("/usages")
|
||||
public ResponseEntity<List<PublicMediaUsageDto>> getUsageMedia(@RequestParam String usageType,
|
||||
@RequestParam String usageKey) {
|
||||
return ResponseEntity.ok(publicMediaQueryService.getUsageMedia(usageType, usageKey));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.printcalculator.dto;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public class PublicMediaUsageDto {
|
||||
private UUID mediaAssetId;
|
||||
private String title;
|
||||
private String altText;
|
||||
private String usageType;
|
||||
private String usageKey;
|
||||
private Integer sortOrder;
|
||||
private Boolean isPrimary;
|
||||
private PublicMediaVariantDto thumb;
|
||||
private PublicMediaVariantDto card;
|
||||
private PublicMediaVariantDto hero;
|
||||
|
||||
public UUID getMediaAssetId() {
|
||||
return mediaAssetId;
|
||||
}
|
||||
|
||||
public void setMediaAssetId(UUID mediaAssetId) {
|
||||
this.mediaAssetId = mediaAssetId;
|
||||
}
|
||||
|
||||
public String getTitle() {
|
||||
return title;
|
||||
}
|
||||
|
||||
public void setTitle(String title) {
|
||||
this.title = title;
|
||||
}
|
||||
|
||||
public String getAltText() {
|
||||
return altText;
|
||||
}
|
||||
|
||||
public void setAltText(String altText) {
|
||||
this.altText = altText;
|
||||
}
|
||||
|
||||
public String getUsageType() {
|
||||
return usageType;
|
||||
}
|
||||
|
||||
public void setUsageType(String usageType) {
|
||||
this.usageType = usageType;
|
||||
}
|
||||
|
||||
public String getUsageKey() {
|
||||
return usageKey;
|
||||
}
|
||||
|
||||
public void setUsageKey(String usageKey) {
|
||||
this.usageKey = usageKey;
|
||||
}
|
||||
|
||||
public Integer getSortOrder() {
|
||||
return sortOrder;
|
||||
}
|
||||
|
||||
public void setSortOrder(Integer sortOrder) {
|
||||
this.sortOrder = sortOrder;
|
||||
}
|
||||
|
||||
public Boolean getIsPrimary() {
|
||||
return isPrimary;
|
||||
}
|
||||
|
||||
public void setIsPrimary(Boolean primary) {
|
||||
isPrimary = primary;
|
||||
}
|
||||
|
||||
public PublicMediaVariantDto getThumb() {
|
||||
return thumb;
|
||||
}
|
||||
|
||||
public void setThumb(PublicMediaVariantDto thumb) {
|
||||
this.thumb = thumb;
|
||||
}
|
||||
|
||||
public PublicMediaVariantDto getCard() {
|
||||
return card;
|
||||
}
|
||||
|
||||
public void setCard(PublicMediaVariantDto card) {
|
||||
this.card = card;
|
||||
}
|
||||
|
||||
public PublicMediaVariantDto getHero() {
|
||||
return hero;
|
||||
}
|
||||
|
||||
public void setHero(PublicMediaVariantDto hero) {
|
||||
this.hero = hero;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.printcalculator.dto;
|
||||
|
||||
public class PublicMediaVariantDto {
|
||||
private String avifUrl;
|
||||
private String webpUrl;
|
||||
private String jpegUrl;
|
||||
|
||||
public String getAvifUrl() {
|
||||
return avifUrl;
|
||||
}
|
||||
|
||||
public void setAvifUrl(String avifUrl) {
|
||||
this.avifUrl = avifUrl;
|
||||
}
|
||||
|
||||
public String getWebpUrl() {
|
||||
return webpUrl;
|
||||
}
|
||||
|
||||
public void setWebpUrl(String webpUrl) {
|
||||
this.webpUrl = webpUrl;
|
||||
}
|
||||
|
||||
public String getJpegUrl() {
|
||||
return jpegUrl;
|
||||
}
|
||||
|
||||
public void setJpegUrl(String jpegUrl) {
|
||||
this.jpegUrl = jpegUrl;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,9 @@ public interface MediaUsageRepository extends JpaRepository<MediaUsage, UUID> {
|
||||
|
||||
List<MediaUsage> findByMediaAsset_IdIn(Collection<UUID> mediaAssetIds);
|
||||
|
||||
List<MediaUsage> findByUsageTypeAndUsageKeyAndIsActiveTrueOrderBySortOrderAscCreatedAtAsc(String usageType,
|
||||
String usageKey);
|
||||
|
||||
@Query("""
|
||||
select usage from MediaUsage usage
|
||||
where usage.usageType = :usageType
|
||||
|
||||
@@ -328,6 +328,7 @@ public class AdminMediaControllerService {
|
||||
String storageFolder = extractStorageFolder(asset.getStorageKey());
|
||||
|
||||
List<PendingGeneratedVariant> pendingVariants = new ArrayList<>();
|
||||
Set<String> skippedFormats = new LinkedHashSet<>();
|
||||
for (PresetDefinition preset : PRESETS) {
|
||||
VariantDimensions dimensions = computeVariantDimensions(
|
||||
asset.getWidthPx(),
|
||||
@@ -336,6 +337,10 @@ public class AdminMediaControllerService {
|
||||
);
|
||||
|
||||
for (String format : List.of(FORMAT_JPEG, FORMAT_WEBP, FORMAT_AVIF)) {
|
||||
if (!mediaFfmpegService.canEncode(format)) {
|
||||
skippedFormats.add(format);
|
||||
continue;
|
||||
}
|
||||
String extension = GENERATED_FORMAT_EXTENSIONS.get(format);
|
||||
Path outputFile = generatedDirectory.resolve(preset.name() + "." + extension);
|
||||
mediaFfmpegService.generateVariant(sourceFile, outputFile, dimensions.widthPx(), dimensions.heightPx(), format);
|
||||
@@ -356,6 +361,14 @@ public class AdminMediaControllerService {
|
||||
}
|
||||
}
|
||||
|
||||
if (!skippedFormats.isEmpty()) {
|
||||
logger.warn(
|
||||
"Skipping media formats for asset {} because FFmpeg encoders are unavailable: {}",
|
||||
asset.getId(),
|
||||
String.join(", ", skippedFormats)
|
||||
);
|
||||
}
|
||||
|
||||
List<String> storedKeys = new ArrayList<>();
|
||||
try {
|
||||
for (PendingGeneratedVariant pendingVariant : pendingVariants) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.printcalculator.service.media;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@@ -9,15 +11,31 @@ import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
@Service
|
||||
public class MediaFfmpegService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(MediaFfmpegService.class);
|
||||
|
||||
private static final Map<String, List<String>> ENCODER_CANDIDATES = Map.of(
|
||||
"JPEG", List.of("mjpeg"),
|
||||
"WEBP", List.of("libwebp", "webp"),
|
||||
"AVIF", List.of("libaom-av1", "librav1e", "libsvtav1")
|
||||
);
|
||||
|
||||
private final String ffmpegPath;
|
||||
private final Set<String> availableEncoders;
|
||||
|
||||
public MediaFfmpegService(@Value("${media.ffmpeg.path:ffmpeg}") String ffmpegPath) {
|
||||
this.ffmpegPath = ffmpegPath;
|
||||
this.availableEncoders = Collections.unmodifiableSet(loadAvailableEncoders());
|
||||
}
|
||||
|
||||
public void generateVariant(Path source, Path target, int widthPx, int heightPx, String format) throws IOException {
|
||||
@@ -25,6 +43,11 @@ public class MediaFfmpegService {
|
||||
throw new IllegalArgumentException("Variant dimensions must be positive.");
|
||||
}
|
||||
|
||||
String encoder = resolveEncoder(format);
|
||||
if (encoder == null) {
|
||||
throw new IOException("FFmpeg encoder not available for media format " + format + ".");
|
||||
}
|
||||
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add(ffmpegPath);
|
||||
command.add("-y");
|
||||
@@ -42,19 +65,19 @@ public class MediaFfmpegService {
|
||||
switch (format) {
|
||||
case "JPEG" -> {
|
||||
command.add("-c:v");
|
||||
command.add("mjpeg");
|
||||
command.add(encoder);
|
||||
command.add("-q:v");
|
||||
command.add("2");
|
||||
}
|
||||
case "WEBP" -> {
|
||||
command.add("-c:v");
|
||||
command.add("libwebp");
|
||||
command.add(encoder);
|
||||
command.add("-quality");
|
||||
command.add("82");
|
||||
}
|
||||
case "AVIF" -> {
|
||||
command.add("-c:v");
|
||||
command.add("libaom-av1");
|
||||
command.add(encoder);
|
||||
command.add("-still-picture");
|
||||
command.add("1");
|
||||
command.add("-crf");
|
||||
@@ -86,6 +109,68 @@ public class MediaFfmpegService {
|
||||
}
|
||||
}
|
||||
|
||||
public boolean canEncode(String format) {
|
||||
return resolveEncoder(format) != null;
|
||||
}
|
||||
|
||||
private String resolveEncoder(String format) {
|
||||
if (format == null) {
|
||||
return null;
|
||||
}
|
||||
List<String> candidates = ENCODER_CANDIDATES.get(format.trim().toUpperCase(Locale.ROOT));
|
||||
if (candidates == null) {
|
||||
return null;
|
||||
}
|
||||
return candidates.stream()
|
||||
.filter(availableEncoders::contains)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private Set<String> loadAvailableEncoders() {
|
||||
List<String> command = List.of(ffmpegPath, "-hide_banner", "-encoders");
|
||||
try {
|
||||
Process process = new ProcessBuilder(command).redirectErrorStream(true).start();
|
||||
String output;
|
||||
try (InputStream processStream = process.getInputStream()) {
|
||||
output = new String(processStream.readAllBytes(), StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
int exitCode = process.waitFor();
|
||||
if (exitCode != 0) {
|
||||
logger.warn("Unable to inspect FFmpeg encoders. Falling back to empty encoder list.");
|
||||
return Set.of();
|
||||
}
|
||||
return parseAvailableEncoders(output);
|
||||
} catch (Exception e) {
|
||||
logger.warn("Unable to inspect FFmpeg encoders. Falling back to empty encoder list.", e);
|
||||
return Set.of();
|
||||
}
|
||||
}
|
||||
|
||||
private Set<String> parseAvailableEncoders(String output) {
|
||||
if (output == null || output.isBlank()) {
|
||||
return Set.of();
|
||||
}
|
||||
|
||||
Set<String> encoders = new LinkedHashSet<>();
|
||||
for (String line : output.split("\\R")) {
|
||||
String trimmed = line.trim();
|
||||
if (trimmed.isBlank() || trimmed.startsWith("--") || trimmed.startsWith("Encoders:")) {
|
||||
continue;
|
||||
}
|
||||
if (trimmed.length() < 7) {
|
||||
continue;
|
||||
}
|
||||
String[] parts = trimmed.split("\\s+", 3);
|
||||
if (parts.length < 2) {
|
||||
continue;
|
||||
}
|
||||
encoders.add(parts[1]);
|
||||
}
|
||||
return encoders;
|
||||
}
|
||||
|
||||
private String truncate(String output) {
|
||||
if (output == null || output.isBlank()) {
|
||||
return "";
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
package com.printcalculator.service.media;
|
||||
|
||||
import com.printcalculator.dto.PublicMediaUsageDto;
|
||||
import com.printcalculator.dto.PublicMediaVariantDto;
|
||||
import com.printcalculator.entity.MediaAsset;
|
||||
import com.printcalculator.entity.MediaUsage;
|
||||
import com.printcalculator.entity.MediaVariant;
|
||||
import com.printcalculator.repository.MediaUsageRepository;
|
||||
import com.printcalculator.repository.MediaVariantRepository;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@Transactional(readOnly = true)
|
||||
public class PublicMediaQueryService {
|
||||
|
||||
private static final String STATUS_READY = "READY";
|
||||
private static final String VISIBILITY_PUBLIC = "PUBLIC";
|
||||
private static final String FORMAT_JPEG = "JPEG";
|
||||
private static final String FORMAT_WEBP = "WEBP";
|
||||
private static final String FORMAT_AVIF = "AVIF";
|
||||
|
||||
private final MediaUsageRepository mediaUsageRepository;
|
||||
private final MediaVariantRepository mediaVariantRepository;
|
||||
private final MediaStorageService mediaStorageService;
|
||||
|
||||
public PublicMediaQueryService(MediaUsageRepository mediaUsageRepository,
|
||||
MediaVariantRepository mediaVariantRepository,
|
||||
MediaStorageService mediaStorageService) {
|
||||
this.mediaUsageRepository = mediaUsageRepository;
|
||||
this.mediaVariantRepository = mediaVariantRepository;
|
||||
this.mediaStorageService = mediaStorageService;
|
||||
}
|
||||
|
||||
public List<PublicMediaUsageDto> getUsageMedia(String usageType, String usageKey) {
|
||||
String normalizedUsageType = normalizeUsageType(usageType);
|
||||
String normalizedUsageKey = normalizeUsageKey(usageKey);
|
||||
|
||||
List<MediaUsage> usages = mediaUsageRepository
|
||||
.findByUsageTypeAndUsageKeyAndIsActiveTrueOrderBySortOrderAscCreatedAtAsc(
|
||||
normalizedUsageType,
|
||||
normalizedUsageKey
|
||||
)
|
||||
.stream()
|
||||
.filter(this::isPublicReadyUsage)
|
||||
.sorted(Comparator
|
||||
.comparing(MediaUsage::getSortOrder, Comparator.nullsLast(Integer::compareTo))
|
||||
.thenComparing(MediaUsage::getCreatedAt, Comparator.nullsLast(OffsetDateTime::compareTo)))
|
||||
.toList();
|
||||
|
||||
if (usages.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
List<UUID> assetIds = usages.stream()
|
||||
.map(MediaUsage::getMediaAsset)
|
||||
.filter(Objects::nonNull)
|
||||
.map(MediaAsset::getId)
|
||||
.filter(Objects::nonNull)
|
||||
.distinct()
|
||||
.toList();
|
||||
|
||||
Map<UUID, List<MediaVariant>> variantsByAssetId = mediaVariantRepository.findByMediaAsset_IdIn(assetIds)
|
||||
.stream()
|
||||
.filter(variant -> !Objects.equals("ORIGINAL", variant.getFormat()))
|
||||
.collect(Collectors.groupingBy(variant -> variant.getMediaAsset().getId()));
|
||||
|
||||
return usages.stream()
|
||||
.map(usage -> toDto(
|
||||
usage,
|
||||
variantsByAssetId.getOrDefault(usage.getMediaAsset().getId(), List.of())
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private boolean isPublicReadyUsage(MediaUsage usage) {
|
||||
MediaAsset asset = usage.getMediaAsset();
|
||||
return asset != null
|
||||
&& STATUS_READY.equals(asset.getStatus())
|
||||
&& VISIBILITY_PUBLIC.equals(asset.getVisibility());
|
||||
}
|
||||
|
||||
private PublicMediaUsageDto toDto(MediaUsage usage, List<MediaVariant> variants) {
|
||||
Map<String, Map<String, MediaVariant>> variantsByPresetAndFormat = variants.stream()
|
||||
.collect(Collectors.groupingBy(
|
||||
MediaVariant::getVariantName,
|
||||
Collectors.toMap(MediaVariant::getFormat, Function.identity(), (left, right) -> right)
|
||||
));
|
||||
|
||||
PublicMediaUsageDto dto = new PublicMediaUsageDto();
|
||||
dto.setMediaAssetId(usage.getMediaAsset().getId());
|
||||
dto.setTitle(usage.getMediaAsset().getTitle());
|
||||
dto.setAltText(usage.getMediaAsset().getAltText());
|
||||
dto.setUsageType(usage.getUsageType());
|
||||
dto.setUsageKey(usage.getUsageKey());
|
||||
dto.setSortOrder(usage.getSortOrder());
|
||||
dto.setIsPrimary(usage.getIsPrimary());
|
||||
dto.setThumb(buildPresetDto(variantsByPresetAndFormat.get("thumb")));
|
||||
dto.setCard(buildPresetDto(variantsByPresetAndFormat.get("card")));
|
||||
dto.setHero(buildPresetDto(variantsByPresetAndFormat.get("hero")));
|
||||
return dto;
|
||||
}
|
||||
|
||||
private PublicMediaVariantDto buildPresetDto(Map<String, MediaVariant> variantsByFormat) {
|
||||
PublicMediaVariantDto dto = new PublicMediaVariantDto();
|
||||
if (variantsByFormat == null || variantsByFormat.isEmpty()) {
|
||||
return dto;
|
||||
}
|
||||
|
||||
dto.setAvifUrl(buildVariantUrl(variantsByFormat.get(FORMAT_AVIF)));
|
||||
dto.setWebpUrl(buildVariantUrl(variantsByFormat.get(FORMAT_WEBP)));
|
||||
dto.setJpegUrl(buildVariantUrl(variantsByFormat.get(FORMAT_JPEG)));
|
||||
return dto;
|
||||
}
|
||||
|
||||
private String buildVariantUrl(MediaVariant variant) {
|
||||
if (variant == null || variant.getStorageKey() == null || variant.getStorageKey().isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return mediaStorageService.buildPublicUrl(variant.getStorageKey());
|
||||
}
|
||||
|
||||
private String normalizeUsageType(String usageType) {
|
||||
if (usageType == null || usageType.isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "usageType is required.");
|
||||
}
|
||||
return usageType.trim().toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private String normalizeUsageKey(String usageKey) {
|
||||
if (usageKey == null || usageKey.isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "usageKey is required.");
|
||||
}
|
||||
return usageKey.trim();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user