dev #37

Merged
JoeKung merged 47 commits from dev into main 2026-03-10 17:43:46 +01:00
7 changed files with 186 additions and 12 deletions
Showing only changes of commit a4facf74d7 - Show all commits

View File

@@ -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/`) - `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 - `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 - `MEDIA_UPLOAD_MAX_FILE_SIZE_BYTES` per il limite per asset immagine
```bash ```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). 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 ### 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 ### Database connection
Verifica le credenziali in `application.properties`. Se usi Docker, puoi passare `DB_URL`, `DB_USERNAME` e `DB_PASSWORD` come variabili d'ambiente. Verifica le credenziali in `application.properties`. Se usi Docker, puoi passare `DB_URL`, `DB_USERNAME` e `DB_PASSWORD` come variabili d'ambiente.

View File

@@ -14,7 +14,7 @@ ARG ORCA_VERSION=2.3.1
ARG ORCA_DOWNLOAD_URL ARG ORCA_DOWNLOAD_URL
# Install system dependencies for OrcaSlicer and media processing. # 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; \ RUN set -eux; \
apt-get update; \ apt-get update; \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ 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:]]mjpeg[[:space:]]' /tmp/ffmpeg-encoders.txt; \
grep -Eq '[[:space:]](libwebp|webp)[[: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; \ 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 -f /tmp/ffmpeg-encoders.txt; \
rm -rf /var/lib/apt/lists/* 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 # Set Slicer Path env variable for Java app
ENV SLICER_PATH="/opt/orcaslicer/AppRun" ENV SLICER_PATH="/opt/orcaslicer/AppRun"
ENV ASSIMP_PATH="assimp" 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 WORKDIR /app
# Copy JAR from build stage # Copy JAR from build stage

View File

@@ -1,10 +1,61 @@
#!/bin/sh #!/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 "----------------------------------------------------------------"
echo "Starting Backend Application" echo "Starting Backend Application"
echo "DB_URL: $DB_URL" echo "DB_URL: $DB_URL"
echo "DB_USERNAME: $DB_USERNAME" echo "DB_USERNAME: $DB_USERNAME"
echo "SPRING_DATASOURCE_URL: $SPRING_DATASOURCE_URL" echo "SPRING_DATASOURCE_URL: $SPRING_DATASOURCE_URL"
echo "SLICER_PATH: $SLICER_PATH" echo "SLICER_PATH: $SLICER_PATH"
echo "MEDIA_FFMPEG_PATH: $MEDIA_FFMPEG_PATH"
echo "----------------------------------------------------------------" echo "----------------------------------------------------------------"
# Determine which environment variables to use for database connection # Determine which environment variables to use for database connection

View File

@@ -402,7 +402,7 @@ public class AdminMediaControllerService {
if (!skippedFormats.isEmpty()) { if (!skippedFormats.isEmpty()) {
logger.warn( logger.warn(
"Skipping media formats for asset {} because FFmpeg encoders are unavailable: {}", "Skipping media formats for asset {} because FFmpeg support is unavailable: {}",
asset.getId(), asset.getId(),
String.join(", ", skippedFormats) String.join(", ", skippedFormats)
); );

View File

@@ -29,13 +29,18 @@ public class MediaFfmpegService {
"WEBP", List.of("libwebp", "webp"), "WEBP", List.of("libwebp", "webp"),
"AVIF", List.of("libaom-av1", "librav1e", "libsvtav1") "AVIF", List.of("libaom-av1", "librav1e", "libsvtav1")
); );
private static final Map<String, List<String>> REQUIRED_MUXERS = Map.of(
"AVIF", List.of("avif")
);
private final String ffmpegExecutable; private final String ffmpegExecutable;
private final Set<String> availableEncoders; private final Set<String> availableEncoders;
private final Set<String> availableMuxers;
public MediaFfmpegService(@Value("${media.ffmpeg.path:ffmpeg}") String ffmpegPath) { public MediaFfmpegService(@Value("${media.ffmpeg.path:ffmpeg}") String ffmpegPath) {
this.ffmpegExecutable = resolveExecutable(ffmpegPath); this.ffmpegExecutable = resolveExecutable(ffmpegPath);
this.availableEncoders = Collections.unmodifiableSet(loadAvailableEncoders()); 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 { 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); Path targetPath = sanitizeMediaPath(target, "target", false);
Files.createDirectories(targetPath.getParent()); Files.createDirectories(targetPath.getParent());
String encoder = resolveEncoder(format); String normalizedFormat = normalizeFormat(format);
String encoder = resolveEncoder(normalizedFormat);
if (encoder == null) { 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<String> command = new ArrayList<>(); List<String> command = new ArrayList<>();
@@ -66,7 +75,7 @@ public class MediaFfmpegService {
command.add("1"); command.add("1");
command.add("-an"); command.add("-an");
switch (format) { switch (normalizedFormat) {
case "JPEG" -> { case "JPEG" -> {
command.add("-c:v"); command.add("-c:v");
command.add(encoder); command.add(encoder);
@@ -86,8 +95,10 @@ public class MediaFfmpegService {
command.add("30"); command.add("30");
command.add("-b:v"); command.add("-b:v");
command.add("0"); 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()); command.add(targetPath.toString());
@@ -112,14 +123,16 @@ public class MediaFfmpegService {
} }
public boolean canEncode(String format) { public boolean canEncode(String format) {
return resolveEncoder(format) != null; String normalizedFormat = normalizeFormat(format);
return resolveEncoder(normalizedFormat) != null && hasRequiredMuxer(normalizedFormat);
} }
private String resolveEncoder(String format) { private String resolveEncoder(String format) {
if (format == null) { String normalizedFormat = normalizeFormat(format);
if (normalizedFormat == null) {
return null; return null;
} }
List<String> candidates = ENCODER_CANDIDATES.get(format.trim().toUpperCase(Locale.ROOT)); List<String> candidates = ENCODER_CANDIDATES.get(normalizedFormat);
if (candidates == null) { if (candidates == null) {
return null; return null;
} }
@@ -129,6 +142,21 @@ public class MediaFfmpegService {
.orElse(null); .orElse(null);
} }
private boolean hasRequiredMuxer(String format) {
List<String> 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<String> loadAvailableEncoders() { private Set<String> loadAvailableEncoders() {
List<String> command = List.of(ffmpegExecutable, "-hide_banner", "-encoders"); List<String> command = List.of(ffmpegExecutable, "-hide_banner", "-encoders");
try { try {
@@ -154,6 +182,31 @@ public class MediaFfmpegService {
} }
} }
private Set<String> loadAvailableMuxers() {
List<String> 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<String> command) throws IOException { private Process startValidatedProcess(List<String> command) throws IOException {
// nosemgrep: java.lang.security.audit.command-injection-process-builder.command-injection-process-builder // nosemgrep: java.lang.security.audit.command-injection-process-builder.command-injection-process-builder
return new ProcessBuilder(List.copyOf(command)) return new ProcessBuilder(List.copyOf(command))
@@ -264,6 +317,26 @@ public class MediaFfmpegService {
return encoders; return encoders;
} }
private Set<String> parseAvailableMuxers(String output) {
if (output == null || output.isBlank()) {
return Set.of();
}
Set<String> 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) { private String truncate(String output) {
if (output == null || output.isBlank()) { if (output == null || output.isBlank()) {
return ""; return "";

View File

@@ -10,6 +10,7 @@ import java.nio.file.attribute.PosixFilePermission;
import java.util.Set; import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals; 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.assertTrue;
import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -80,6 +81,12 @@ class MediaFfmpegServiceTest {
EOF EOF
exit 0 exit 0
fi fi
if [ "$1" = "-hide_banner" ] && [ "$2" = "-muxers" ]; then
cat <<'EOF'
E avif
EOF
exit 0
fi
for arg in "$@"; do for arg in "$@"; do
if [ "$arg" = "-still-picture" ]; then if [ "$arg" = "-still-picture" ]; then
@@ -117,4 +124,42 @@ class MediaFfmpegServiceTest {
assertTrue(Files.exists(target)); assertTrue(Files.exists(target));
assertEquals("ok", Files.readString(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"));
}
} }

View File

@@ -33,7 +33,7 @@ services:
- PROFILES_DIR=/app/profiles - PROFILES_DIR=/app/profiles
- MEDIA_STORAGE_ROOT=${MEDIA_STORAGE_ROOT:-/app/storage_media} - MEDIA_STORAGE_ROOT=${MEDIA_STORAGE_ROOT:-/app/storage_media}
- SHOP_STORAGE_ROOT=${SHOP_STORAGE_ROOT:-/app/storage_shop} - 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} - MEDIA_UPLOAD_MAX_FILE_SIZE_BYTES=${MEDIA_UPLOAD_MAX_FILE_SIZE_BYTES:-26214400}
restart: always restart: always
logging: logging: