diff --git a/README.md b/README.md index 686dd5a..0bb60c6 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Configura il percorso di OrcaSlicer in `backend/src/main/resources/application.p - `MEDIA_STORAGE_ROOT` per la root `storage_media` usata dal backend (`original/`, `public/`, `private/`) - `SHOP_STORAGE_ROOT` per la root `storage_shop` usata dal backend per i modelli dei prodotti shop -- `MEDIA_FFMPEG_PATH` per il binario `ffmpeg` +- `MEDIA_FFMPEG_PATH` per il binario `ffmpeg` (nel deploy Docker default: `/usr/bin/ffmpeg`) - `MEDIA_UPLOAD_MAX_FILE_SIZE_BYTES` per il limite per asset immagine ```bash @@ -107,7 +107,7 @@ Operativamente: Assicurati che `slicer.path` punti al binario corretto. Su macOS è solitamente `/Applications/OrcaSlicer.app/Contents/MacOS/OrcaSlicer`. Su Linux è il percorso all'AppImage (estratta o meno). ### FFmpeg e media pubblici -Verifica che `MEDIA_FFMPEG_PATH` punti a un `ffmpeg` con supporto JPEG, WebP e AVIF. Se gli URL media restituiti dalle API admin non sono raggiungibili, controlla che `APP_FRONTEND_BASE_URL` punti al dominio corretto, che `location /media/` sia esposto da Nginx e che il volume `storage_media` sia montato correttamente. +Verifica che `MEDIA_FFMPEG_PATH` punti a un `ffmpeg` con supporto JPEG, WebP e AVIF (encoder + muxer AVIF). Nel container backend usa `/usr/bin/ffmpeg` per evitare binari bundled da OrcaSlicer con supporto incompleto. Se gli URL media restituiti dalle API admin non sono raggiungibili, controlla che `APP_FRONTEND_BASE_URL` punti al dominio corretto, che `location /media/` sia esposto da Nginx e che il volume `storage_media` sia montato correttamente. ### Database connection Verifica le credenziali in `application.properties`. Se usi Docker, puoi passare `DB_URL`, `DB_USERNAME` e `DB_PASSWORD` come variabili d'ambiente. diff --git a/backend/Dockerfile b/backend/Dockerfile index f9ce411..652a98c 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -14,7 +14,7 @@ ARG ORCA_VERSION=2.3.1 ARG ORCA_DOWNLOAD_URL # Install system dependencies for OrcaSlicer and media processing. -# The build fails fast if the packaged ffmpeg lacks JPEG/WebP/AVIF encoders. +# The build fails fast if the packaged ffmpeg lacks JPEG/WebP/AVIF support. RUN set -eux; \ apt-get update; \ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ @@ -30,6 +30,9 @@ RUN set -eux; \ grep -Eq '[[:space:]]mjpeg[[:space:]]' /tmp/ffmpeg-encoders.txt; \ grep -Eq '[[:space:]](libwebp|webp)[[:space:]]' /tmp/ffmpeg-encoders.txt; \ grep -Eq '[[:space:]](libaom-av1|librav1e|libsvtav1)[[:space:]]' /tmp/ffmpeg-encoders.txt; \ + ffmpeg -hide_banner -muxers > /tmp/ffmpeg-muxers.txt; \ + grep -Eq '[[:space:]]avif([[:space:]]|,|$)' /tmp/ffmpeg-muxers.txt; \ + rm -f /tmp/ffmpeg-muxers.txt; \ rm -f /tmp/ffmpeg-encoders.txt; \ rm -rf /var/lib/apt/lists/* @@ -71,6 +74,8 @@ ENV PATH="/opt/orcaslicer/usr/bin:${PATH}" # Set Slicer Path env variable for Java app ENV SLICER_PATH="/opt/orcaslicer/AppRun" ENV ASSIMP_PATH="assimp" +# Keep media generation on the system ffmpeg (Orca bundles can miss AVIF muxer support). +ENV MEDIA_FFMPEG_PATH="/usr/bin/ffmpeg" WORKDIR /app # Copy JAR from build stage diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh index cd79e27..376b90a 100644 --- a/backend/entrypoint.sh +++ b/backend/entrypoint.sh @@ -1,10 +1,61 @@ #!/bin/sh +set -e + +# In container default to system ffmpeg to avoid Orca-bundled binaries with partial codec support. +if [ -z "${MEDIA_FFMPEG_PATH:-}" ]; then + MEDIA_FFMPEG_PATH="/usr/bin/ffmpeg" +fi +export MEDIA_FFMPEG_PATH + +validate_ffmpeg_support() { + ffmpeg_bin="$1" + if ! command -v "$ffmpeg_bin" >/dev/null 2>&1; then + echo "ERROR: FFmpeg executable not found: ${ffmpeg_bin}" >&2 + exit 11 + fi + + encoders="$(mktemp)" + muxers="$(mktemp)" + trap 'rm -f "$encoders" "$muxers"' EXIT + + "$ffmpeg_bin" -hide_banner -encoders > "$encoders" 2>&1 || { + echo "ERROR: Unable to inspect FFmpeg encoders from ${ffmpeg_bin}" >&2 + cat "$encoders" >&2 + exit 12 + } + "$ffmpeg_bin" -hide_banner -muxers > "$muxers" 2>&1 || { + echo "ERROR: Unable to inspect FFmpeg muxers from ${ffmpeg_bin}" >&2 + cat "$muxers" >&2 + exit 13 + } + + grep -Eq '[[:space:]]mjpeg[[:space:]]' "$encoders" || { + echo "ERROR: FFmpeg '${ffmpeg_bin}' missing JPEG encoder (mjpeg)." >&2 + exit 14 + } + grep -Eq '[[:space:]](libwebp|webp)[[:space:]]' "$encoders" || { + echo "ERROR: FFmpeg '${ffmpeg_bin}' missing WebP encoder." >&2 + exit 15 + } + grep -Eq '[[:space:]](libaom-av1|librav1e|libsvtav1)[[:space:]]' "$encoders" || { + echo "ERROR: FFmpeg '${ffmpeg_bin}' missing AVIF-capable encoder." >&2 + exit 16 + } + grep -Eq '[[:space:]]avif([[:space:]]|,|$)' "$muxers" || { + echo "ERROR: FFmpeg '${ffmpeg_bin}' missing AVIF muxer." >&2 + exit 17 + } +} + +validate_ffmpeg_support "$MEDIA_FFMPEG_PATH" + echo "----------------------------------------------------------------" echo "Starting Backend Application" echo "DB_URL: $DB_URL" echo "DB_USERNAME: $DB_USERNAME" echo "SPRING_DATASOURCE_URL: $SPRING_DATASOURCE_URL" echo "SLICER_PATH: $SLICER_PATH" +echo "MEDIA_FFMPEG_PATH: $MEDIA_FFMPEG_PATH" echo "----------------------------------------------------------------" # Determine which environment variables to use for database connection diff --git a/backend/src/main/java/com/printcalculator/service/admin/AdminMediaControllerService.java b/backend/src/main/java/com/printcalculator/service/admin/AdminMediaControllerService.java index 0210f73..0e0624a 100644 --- a/backend/src/main/java/com/printcalculator/service/admin/AdminMediaControllerService.java +++ b/backend/src/main/java/com/printcalculator/service/admin/AdminMediaControllerService.java @@ -402,7 +402,7 @@ public class AdminMediaControllerService { if (!skippedFormats.isEmpty()) { logger.warn( - "Skipping media formats for asset {} because FFmpeg encoders are unavailable: {}", + "Skipping media formats for asset {} because FFmpeg support is unavailable: {}", asset.getId(), String.join(", ", skippedFormats) ); diff --git a/backend/src/main/java/com/printcalculator/service/media/MediaFfmpegService.java b/backend/src/main/java/com/printcalculator/service/media/MediaFfmpegService.java index aac63e8..f197534 100644 --- a/backend/src/main/java/com/printcalculator/service/media/MediaFfmpegService.java +++ b/backend/src/main/java/com/printcalculator/service/media/MediaFfmpegService.java @@ -29,13 +29,18 @@ public class MediaFfmpegService { "WEBP", List.of("libwebp", "webp"), "AVIF", List.of("libaom-av1", "librav1e", "libsvtav1") ); + private static final Map> REQUIRED_MUXERS = Map.of( + "AVIF", List.of("avif") + ); private final String ffmpegExecutable; private final Set availableEncoders; + private final Set availableMuxers; public MediaFfmpegService(@Value("${media.ffmpeg.path:ffmpeg}") String ffmpegPath) { this.ffmpegExecutable = resolveExecutable(ffmpegPath); this.availableEncoders = Collections.unmodifiableSet(loadAvailableEncoders()); + this.availableMuxers = Collections.unmodifiableSet(loadAvailableMuxers()); } public void generateVariant(Path source, Path target, int widthPx, int heightPx, String format) throws IOException { @@ -47,9 +52,13 @@ public class MediaFfmpegService { Path targetPath = sanitizeMediaPath(target, "target", false); Files.createDirectories(targetPath.getParent()); - String encoder = resolveEncoder(format); + String normalizedFormat = normalizeFormat(format); + String encoder = resolveEncoder(normalizedFormat); if (encoder == null) { - throw new IOException("FFmpeg encoder not available for media format " + format + "."); + throw new IOException("FFmpeg encoder not available for media format " + normalizedFormat + "."); + } + if (!hasRequiredMuxer(normalizedFormat)) { + throw new IOException("FFmpeg muxer not available for media format " + normalizedFormat + "."); } List command = new ArrayList<>(); @@ -66,7 +75,7 @@ public class MediaFfmpegService { command.add("1"); command.add("-an"); - switch (format) { + switch (normalizedFormat) { case "JPEG" -> { command.add("-c:v"); command.add(encoder); @@ -86,8 +95,10 @@ public class MediaFfmpegService { command.add("30"); command.add("-b:v"); command.add("0"); + command.add("-f"); + command.add("avif"); } - default -> throw new IllegalArgumentException("Unsupported media format: " + format); + default -> throw new IllegalArgumentException("Unsupported media format: " + normalizedFormat); } command.add(targetPath.toString()); @@ -112,14 +123,16 @@ public class MediaFfmpegService { } public boolean canEncode(String format) { - return resolveEncoder(format) != null; + String normalizedFormat = normalizeFormat(format); + return resolveEncoder(normalizedFormat) != null && hasRequiredMuxer(normalizedFormat); } private String resolveEncoder(String format) { - if (format == null) { + String normalizedFormat = normalizeFormat(format); + if (normalizedFormat == null) { return null; } - List candidates = ENCODER_CANDIDATES.get(format.trim().toUpperCase(Locale.ROOT)); + List candidates = ENCODER_CANDIDATES.get(normalizedFormat); if (candidates == null) { return null; } @@ -129,6 +142,21 @@ public class MediaFfmpegService { .orElse(null); } + private boolean hasRequiredMuxer(String format) { + List requiredMuxers = REQUIRED_MUXERS.get(format); + if (requiredMuxers == null || requiredMuxers.isEmpty()) { + return true; + } + return requiredMuxers.stream().anyMatch(availableMuxers::contains); + } + + private String normalizeFormat(String format) { + if (format == null) { + return null; + } + return format.trim().toUpperCase(Locale.ROOT); + } + private Set loadAvailableEncoders() { List command = List.of(ffmpegExecutable, "-hide_banner", "-encoders"); try { @@ -154,6 +182,31 @@ public class MediaFfmpegService { } } + private Set loadAvailableMuxers() { + List command = List.of(ffmpegExecutable, "-hide_banner", "-muxers"); + try { + Process process = startValidatedProcess(command); + 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 muxers. Falling back to empty muxer list."); + return Set.of(); + } + return parseAvailableMuxers(output); + } catch (Exception e) { + logger.warn( + "Unable to inspect FFmpeg muxers for executable '{}'. Falling back to empty muxer list. {}", + ffmpegExecutable, + e.getMessage() + ); + return Set.of(); + } + } + private Process startValidatedProcess(List command) throws IOException { // nosemgrep: java.lang.security.audit.command-injection-process-builder.command-injection-process-builder return new ProcessBuilder(List.copyOf(command)) @@ -264,6 +317,26 @@ public class MediaFfmpegService { return encoders; } + private Set parseAvailableMuxers(String output) { + if (output == null || output.isBlank()) { + return Set.of(); + } + + Set muxers = new LinkedHashSet<>(); + for (String line : output.split("\\R")) { + String trimmed = line.trim(); + if (trimmed.isBlank() || trimmed.startsWith("--") || trimmed.startsWith("Muxers:")) { + continue; + } + String[] parts = trimmed.split("\\s+", 3); + if (parts.length < 2) { + continue; + } + muxers.add(parts[1]); + } + return muxers; + } + private String truncate(String output) { if (output == null || output.isBlank()) { return ""; diff --git a/backend/src/test/java/com/printcalculator/service/media/MediaFfmpegServiceTest.java b/backend/src/test/java/com/printcalculator/service/media/MediaFfmpegServiceTest.java index 386fa7a..4cf683a 100644 --- a/backend/src/test/java/com/printcalculator/service/media/MediaFfmpegServiceTest.java +++ b/backend/src/test/java/com/printcalculator/service/media/MediaFfmpegServiceTest.java @@ -10,6 +10,7 @@ import java.nio.file.attribute.PosixFilePermission; import java.util.Set; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -80,6 +81,12 @@ class MediaFfmpegServiceTest { EOF exit 0 fi + if [ "$1" = "-hide_banner" ] && [ "$2" = "-muxers" ]; then + cat <<'EOF' + E avif + EOF + exit 0 + fi for arg in "$@"; do if [ "$arg" = "-still-picture" ]; then @@ -117,4 +124,42 @@ class MediaFfmpegServiceTest { assertTrue(Files.exists(target)); assertEquals("ok", Files.readString(target)); } + + @Test + void canEncode_avifShouldRequireMuxerAvailability() throws Exception { + Path fakeFfmpeg = tempDir.resolve("fake-ffmpeg-no-avif-muxer.sh"); + Files.writeString( + fakeFfmpeg, + """ + #!/bin/sh + if [ "$1" = "-hide_banner" ] && [ "$2" = "-encoders" ]; then + cat <<'EOF' + V..... mjpeg + V..... libaom-av1 + EOF + exit 0 + fi + if [ "$1" = "-hide_banner" ] && [ "$2" = "-muxers" ]; then + cat <<'EOF' + E mp4 + EOF + exit 0 + fi + exit 0 + """ + ); + Files.setPosixFilePermissions( + fakeFfmpeg, + Set.of( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, + PosixFilePermission.OWNER_EXECUTE + ) + ); + + MediaFfmpegService service = new MediaFfmpegService(fakeFfmpeg.toString()); + + assertTrue(service.canEncode("JPEG")); + assertFalse(service.canEncode("AVIF")); + } } diff --git a/docker-compose.deploy.yml b/docker-compose.deploy.yml index d306e8a..8a0d0fc 100644 --- a/docker-compose.deploy.yml +++ b/docker-compose.deploy.yml @@ -33,7 +33,7 @@ services: - PROFILES_DIR=/app/profiles - MEDIA_STORAGE_ROOT=${MEDIA_STORAGE_ROOT:-/app/storage_media} - SHOP_STORAGE_ROOT=${SHOP_STORAGE_ROOT:-/app/storage_shop} - - MEDIA_FFMPEG_PATH=${MEDIA_FFMPEG_PATH:-ffmpeg} + - MEDIA_FFMPEG_PATH=${MEDIA_FFMPEG_PATH:-/usr/bin/ffmpeg} - MEDIA_UPLOAD_MAX_FILE_SIZE_BYTES=${MEDIA_UPLOAD_MAX_FILE_SIZE_BYTES:-26214400} restart: always logging: