From 77d7bdb26516c2c48c1b8a45333da5367a0743a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Mon, 9 Mar 2026 20:04:21 +0100 Subject: [PATCH 1/2] fix(back-end): img convert --- .../admin/AdminMediaControllerService.java | 22 +++++++- .../service/media/MediaFfmpegService.java | 2 - .../AdminMediaControllerServiceTest.java | 26 +++++++++ .../service/media/MediaFfmpegServiceTest.java | 56 +++++++++++++++++++ 4 files changed, 103 insertions(+), 3 deletions(-) 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 0e30a60..c0bf0ac 100644 --- a/backend/src/main/java/com/printcalculator/service/admin/AdminMediaControllerService.java +++ b/backend/src/main/java/com/printcalculator/service/admin/AdminMediaControllerService.java @@ -350,7 +350,27 @@ public class AdminMediaControllerService { } String extension = GENERATED_FORMAT_EXTENSIONS.get(format); Path outputFile = generatedDirectory.resolve(preset.name() + "." + extension); - mediaFfmpegService.generateVariant(sourceFile, outputFile, dimensions.widthPx(), dimensions.heightPx(), format); + try { + mediaFfmpegService.generateVariant( + sourceFile, + outputFile, + dimensions.widthPx(), + dimensions.heightPx(), + format + ); + } catch (IOException e) { + if (FORMAT_AVIF.equals(format)) { + skippedFormats.add(format); + logger.warn( + "Skipping AVIF variant generation for asset {} preset '{}' because FFmpeg AVIF generation failed: {}", + asset.getId(), + preset.name(), + e.getMessage() + ); + continue; + } + throw e; + } MediaVariant variant = new MediaVariant(); variant.setMediaAsset(asset); 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 4e50785..aac63e8 100644 --- a/backend/src/main/java/com/printcalculator/service/media/MediaFfmpegService.java +++ b/backend/src/main/java/com/printcalculator/service/media/MediaFfmpegService.java @@ -82,8 +82,6 @@ public class MediaFfmpegService { case "AVIF" -> { command.add("-c:v"); command.add(encoder); - command.add("-still-picture"); - command.add("1"); command.add("-crf"); command.add("30"); command.add("-b:v"); diff --git a/backend/src/test/java/com/printcalculator/service/admin/AdminMediaControllerServiceTest.java b/backend/src/test/java/com/printcalculator/service/admin/AdminMediaControllerServiceTest.java index 861996b..9f54e78 100644 --- a/backend/src/test/java/com/printcalculator/service/admin/AdminMediaControllerServiceTest.java +++ b/backend/src/test/java/com/printcalculator/service/admin/AdminMediaControllerServiceTest.java @@ -51,6 +51,7 @@ import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.nullable; import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @@ -275,6 +276,31 @@ class AdminMediaControllerServiceTest { .noneMatch(variant -> "WEBP".equals(variant.getFormat()) || "AVIF".equals(variant.getFormat()))); } + @Test + void uploadAsset_whenAvifGenerationFails_shouldKeepAssetReadyAndStoreOtherVariants() throws Exception { + when(mediaImageInspector.inspect(any(Path.class))).thenReturn( + new MediaImageInspector.ImageMetadata("image/png", "png", 1600, 900) + ); + doThrow(new java.io.IOException("FFmpeg failed to generate media variant. Unrecognized option 'still-picture'.")) + .when(mediaFfmpegService) + .generateVariant(any(Path.class), any(Path.class), anyInt(), anyInt(), org.mockito.ArgumentMatchers.eq("AVIF")); + + MockMultipartFile file = new MockMultipartFile( + "file", + "landing-hero.png", + "image/png", + "png-image-content".getBytes(StandardCharsets.UTF_8) + ); + + AdminMediaAssetDto dto = service.uploadAsset(file, " Landing hero ", " Main headline ", null); + + assertEquals("READY", dto.getStatus()); + assertEquals(7, dto.getVariants().size()); + assertTrue(dto.getVariants().stream().noneMatch(variant -> "AVIF".equals(variant.getFormat()))); + assertEquals(3, dto.getVariants().stream().filter(variant -> "JPEG".equals(variant.getFormat())).count()); + assertEquals(3, dto.getVariants().stream().filter(variant -> "WEBP".equals(variant.getFormat())).count()); + } + @Test void uploadAsset_withOversizedFile_shouldFailValidationBeforePersistence() { service = new AdminMediaControllerService( 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 d407a2e..386fa7a 100644 --- a/backend/src/test/java/com/printcalculator/service/media/MediaFfmpegServiceTest.java +++ b/backend/src/test/java/com/printcalculator/service/media/MediaFfmpegServiceTest.java @@ -6,8 +6,11 @@ import org.junit.jupiter.api.io.TempDir; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +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.assertTrue; import static org.junit.jupiter.api.Assertions.assertThrows; class MediaFfmpegServiceTest { @@ -61,4 +64,57 @@ class MediaFfmpegServiceTest { assertEquals("Media target file name must not start with '-'.", ex.getMessage()); } + + @Test + void generateVariant_avifShouldNotUseStillPictureFlag() throws Exception { + Path fakeFfmpeg = tempDir.resolve("fake-ffmpeg.sh"); + Files.writeString( + fakeFfmpeg, + """ + #!/bin/sh + if [ "$1" = "-hide_banner" ] && [ "$2" = "-encoders" ]; then + cat <<'EOF' + V..... mjpeg + V..... libwebp + V..... libaom-av1 + EOF + exit 0 + fi + + for arg in "$@"; do + if [ "$arg" = "-still-picture" ]; then + echo "Unrecognized option 'still-picture'. Error splitting the argument list: Option not found" + exit 1 + fi + done + + last_arg="" + for arg in "$@"; do + last_arg="$arg" + done + + mkdir -p "$(dirname "$last_arg")" + printf 'ok' > "$last_arg" + exit 0 + """ + ); + Files.setPosixFilePermissions( + fakeFfmpeg, + Set.of( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, + PosixFilePermission.OWNER_EXECUTE + ) + ); + + MediaFfmpegService service = new MediaFfmpegService(fakeFfmpeg.toString()); + Path source = tempDir.resolve("input.png"); + Path target = tempDir.resolve("output.avif"); + Files.writeString(source, "image"); + + service.generateVariant(source, target, 120, 80, "AVIF"); + + assertTrue(Files.exists(target)); + assertEquals("ok", Files.readString(target)); + } } From 4342e9b1b1ded2399c52403ee949f1899de4f6e7 Mon Sep 17 00:00:00 2001 From: printcalc-ci Date: Tue, 10 Mar 2026 07:32:17 +0000 Subject: [PATCH 2/2] style: apply prettier formatting --- frontend/src/app/core/services/seo.service.ts | 3 +- .../pages/admin-dashboard.component.html | 28 ++++++++++++---- .../admin/pages/admin-dashboard.component.ts | 6 ++-- .../features/checkout/checkout.component.ts | 5 +-- .../app/features/order/order.component.html | 16 ++++++--- .../src/app/features/order/order.component.ts | 4 ++- .../product-card/product-card.component.html | 4 +-- .../product-card/product-card.component.scss | 13 ++++++-- .../shop/product-detail.component.html | 33 ++++++++++++------- .../shop/product-detail.component.scss | 25 +++++++++----- .../features/shop/product-detail.component.ts | 26 +++++++++++---- .../shop/services/shop.service.spec.ts | 4 ++- .../features/shop/services/shop.service.ts | 14 ++++---- .../features/shop/shop-page.component.html | 33 +++++++++++-------- .../features/shop/shop-page.component.scss | 19 ++++++----- .../app/features/shop/shop-page.component.ts | 29 ++++++++++++---- 16 files changed, 177 insertions(+), 85 deletions(-) diff --git a/frontend/src/app/core/services/seo.service.ts b/frontend/src/app/core/services/seo.service.ts index b12a3ad..2637ecc 100644 --- a/frontend/src/app/core/services/seo.service.ts +++ b/frontend/src/app/core/services/seo.service.ts @@ -57,7 +57,8 @@ export class SeoService { this.asString(mergedData['seoDescription']) ?? this.defaultDescription; const robots = this.asString(mergedData['seoRobots']) ?? 'index, follow'; const ogTitle = this.asString(mergedData['ogTitle']) ?? title; - const ogDescription = this.asString(mergedData['ogDescription']) ?? description; + const ogDescription = + this.asString(mergedData['ogDescription']) ?? description; this.applySeoValues(title, description, robots, ogTitle, ogDescription); } diff --git a/frontend/src/app/features/admin/pages/admin-dashboard.component.html b/frontend/src/app/features/admin/pages/admin-dashboard.component.html index 758e27d..64091db 100644 --- a/frontend/src/app/features/admin/pages/admin-dashboard.component.html +++ b/frontend/src/app/features/admin/pages/admin-dashboard.component.html @@ -75,7 +75,10 @@ [ngModel]="orderTypeFilter" (ngModelChange)="onOrderTypeFilterChange($event)" > - @@ -133,7 +136,9 @@ {{ orderKindLabel(selectedOrder) }} @@ -162,7 +167,8 @@ Stato ordine{{ selectedOrder.status }}
- Tipo ordine{{ orderKindLabel(selectedOrder) }} + Tipo ordine{{ orderKindLabel(selectedOrder) }}
Totale {{ getItemColorLabel(item) }} - + ({{ colorCode }}) @@ -300,7 +308,12 @@ @@ -373,7 +386,10 @@

Parametri per file

-
+
{{ item.originalFilename }} {{ getItemMaterialLabel(item) }} | Colore: diff --git a/frontend/src/app/features/admin/pages/admin-dashboard.component.ts b/frontend/src/app/features/admin/pages/admin-dashboard.component.ts index 0d15464..466dcb5 100644 --- a/frontend/src/app/features/admin/pages/admin-dashboard.component.ts +++ b/frontend/src/app/features/admin/pages/admin-dashboard.component.ts @@ -131,7 +131,8 @@ export class AdminDashboardComponent implements OnInit { this.selectedOrder = order; this.selectedStatus = order.status; this.selectedPaymentMethod = order.paymentMethod || 'OTHER'; - this.showPrintDetails = this.showPrintDetails && this.hasPrintItems(order); + this.showPrintDetails = + this.showPrintDetails && this.hasPrintItems(order); this.detailLoading = false; }, error: () => { @@ -446,7 +447,8 @@ export class AdminDashboardComponent implements OnInit { this.selectedStatus = updatedOrder.status; this.selectedPaymentMethod = updatedOrder.paymentMethod || this.selectedPaymentMethod; - this.showPrintDetails = this.showPrintDetails && this.hasPrintItems(updatedOrder); + this.showPrintDetails = + this.showPrintDetails && this.hasPrintItems(updatedOrder); } private applyListFiltersAndSelection(): void { diff --git a/frontend/src/app/features/checkout/checkout.component.ts b/frontend/src/app/features/checkout/checkout.component.ts index bb34b60..c00abc7 100644 --- a/frontend/src/app/features/checkout/checkout.component.ts +++ b/frontend/src/app/features/checkout/checkout.component.ts @@ -393,10 +393,7 @@ export class CheckoutComponent implements OnInit { } private loadStlPreviews(session: any): void { - if ( - !this.sessionId || - !Array.isArray(session?.items) - ) { + if (!this.sessionId || !Array.isArray(session?.items)) { return; } diff --git a/frontend/src/app/features/order/order.component.html b/frontend/src/app/features/order/order.component.html index 2b73d7a..47a0a68 100644 --- a/frontend/src/app/features/order/order.component.html +++ b/frontend/src/app/features/order/order.component.html @@ -161,7 +161,9 @@ {{ @@ -201,10 +203,14 @@
- {{ "CHECKOUT.QTY" | translate }}: {{ item.quantity }} + {{ "CHECKOUT.QTY" | translate }}: {{ item.quantity }} {{ "CHECKOUT.MATERIAL" | translate }}: - {{ item.materialCode || ("ORDER.NOT_AVAILABLE" | translate) }} + {{ + item.materialCode || ("ORDER.NOT_AVAILABLE" | translate) + }} {{ "SHOP.VARIANT" | translate }}: {{ variantLabel }} @@ -252,7 +258,9 @@ {{ orderKindLabel(o) }}
- {{ "ORDER.ITEM_COUNT" | translate }} + {{ + "ORDER.ITEM_COUNT" | translate + }} {{ (o.items || []).length }}
diff --git a/frontend/src/app/features/order/order.component.ts b/frontend/src/app/features/order/order.component.ts index 52c2d8d..16837e6 100644 --- a/frontend/src/app/features/order/order.component.ts +++ b/frontend/src/app/features/order/order.component.ts @@ -263,7 +263,9 @@ export class OrderComponent implements OnInit { return shopName; } - return String(item?.originalFilename ?? this.translate.instant('ORDER.NOT_AVAILABLE')); + return String( + item?.originalFilename ?? this.translate.instant('ORDER.NOT_AVAILABLE'), + ); } itemVariantLabel(item: PublicOrderItem): string | null { diff --git a/frontend/src/app/features/shop/components/product-card/product-card.component.html b/frontend/src/app/features/shop/components/product-card/product-card.component.html index 96d3994..11688b0 100644 --- a/frontend/src/app/features/shop/components/product-card/product-card.component.html +++ b/frontend/src/app/features/shop/components/product-card/product-card.component.html @@ -46,9 +46,7 @@
{{ priceLabel() | currency: "CHF" }} @if (hasPriceRange()) { - {{ - "SHOP.PRICE_FROM" | translate - }} + {{ "SHOP.PRICE_FROM" | translate }} }
diff --git a/frontend/src/app/features/shop/components/product-card/product-card.component.scss b/frontend/src/app/features/shop/components/product-card/product-card.component.scss index 8825c7d..c912bba 100644 --- a/frontend/src/app/features/shop/components/product-card/product-card.component.scss +++ b/frontend/src/app/features/shop/components/product-card/product-card.component.scss @@ -4,8 +4,11 @@ border: 1px solid rgba(16, 24, 32, 0.08); border-radius: 1.1rem; overflow: hidden; - background: - linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 246, 241, 1)); + background: linear-gradient( + 180deg, + rgba(255, 255, 255, 0.98), + rgba(248, 246, 241, 1) + ); box-shadow: 0 18px 40px rgba(16, 24, 32, 0.08); transition: transform 0.2s ease, @@ -24,7 +27,11 @@ display: block; min-height: 244px; background: - radial-gradient(circle at top right, rgba(250, 207, 10, 0.28), transparent 42%), + radial-gradient( + circle at top right, + rgba(250, 207, 10, 0.28), + transparent 42% + ), linear-gradient(160deg, #f7f4ed 0%, #ece7db 100%); } diff --git a/frontend/src/app/features/shop/product-detail.component.html b/frontend/src/app/features/shop/product-detail.component.html index eec9d42..fbe8da1 100644 --- a/frontend/src/app/features/shop/product-detail.component.html +++ b/frontend/src/app/features/shop/product-detail.component.html @@ -44,7 +44,9 @@
- X {{ p.model3d.boundingBoxXMm || 0 | number: "1.0-1" }} mm + X + {{ p.model3d.boundingBoxXMm || 0 | number: "1.0-1" }} mm - Y {{ p.model3d.boundingBoxYMm || 0 | number: "1.0-1" }} mm + Y + {{ p.model3d.boundingBoxYMm || 0 | number: "1.0-1" }} mm - Z {{ p.model3d.boundingBoxZMm || 0 | number: "1.0-1" }} mm + Z + {{ p.model3d.boundingBoxZMm || 0 | number: "1.0-1" }} mm
@@ -133,7 +138,8 @@ {{ "SHOP.IN_CART_LONG" - | translate: { count: selectedVariantCartQuantity() } + | translate + : { count: selectedVariantCartQuantity() } }} } @@ -157,7 +163,9 @@ {{ variant.variantLabel }} } - {{ variant.priceChf | currency: "CHF" }} + {{ + variant.priceChf | currency: "CHF" + }} } @@ -165,9 +173,13 @@
{{ "SHOP.QUANTITY" | translate }}
- + {{ quantity() }} - +
@@ -178,9 +190,8 @@ (click)="addToCart()" > {{ - (isAddingToCart() - ? "SHOP.ADDING" - : "SHOP.ADD_CART") | translate + (isAddingToCart() ? "SHOP.ADDING" : "SHOP.ADD_CART") + | translate }} diff --git a/frontend/src/app/features/shop/product-detail.component.scss b/frontend/src/app/features/shop/product-detail.component.scss index a9e229d..e07aad7 100644 --- a/frontend/src/app/features/shop/product-detail.component.scss +++ b/frontend/src/app/features/shop/product-detail.component.scss @@ -1,7 +1,11 @@ .product-page { padding: var(--space-8) 0 var(--space-12); background: - radial-gradient(circle at top left, rgba(250, 207, 10, 0.18), transparent 20%), + radial-gradient( + circle at top left, + rgba(250, 207, 10, 0.18), + transparent 20% + ), linear-gradient(180deg, #faf7ee 0%, var(--color-bg) 25%); } @@ -40,7 +44,11 @@ border-radius: 1.25rem; border: 1px solid rgba(16, 24, 32, 0.08); background: - radial-gradient(circle at top right, rgba(250, 207, 10, 0.3), transparent 30%), + radial-gradient( + circle at top right, + rgba(250, 207, 10, 0.3), + transparent 30% + ), linear-gradient(160deg, #f8f4ea 0%, #eee8db 100%); box-shadow: 0 18px 42px rgba(16, 24, 32, 0.08); } @@ -325,13 +333,12 @@ h1 { } .skeleton-block { - background: - linear-gradient( - 110deg, - rgba(255, 255, 255, 0.7) 8%, - rgba(238, 235, 226, 0.95) 18%, - rgba(255, 255, 255, 0.7) 33% - ); + background: linear-gradient( + 110deg, + rgba(255, 255, 255, 0.7) 8%, + rgba(238, 235, 226, 0.95) 18%, + rgba(255, 255, 255, 0.7) 33% + ); background-size: 220% 100%; animation: skeleton 1.35s linear infinite; } diff --git a/frontend/src/app/features/shop/product-detail.component.ts b/frontend/src/app/features/shop/product-detail.component.ts index 7d4e8b4..e9e74c5 100644 --- a/frontend/src/app/features/shop/product-detail.component.ts +++ b/frontend/src/app/features/shop/product-detail.component.ts @@ -121,7 +121,9 @@ export class ProductDetailComponent { combineLatest([ toObservable(this.productSlug, { injector: this.injector }), - toObservable(this.languageService.currentLang, { injector: this.injector }), + toObservable(this.languageService.currentLang, { + injector: this.injector, + }), ]) .pipe( tap(() => { @@ -160,13 +162,22 @@ export class ProductDetailComponent { } this.product.set(product); - this.selectedVariantId.set(product.defaultVariant?.id ?? product.variants[0]?.id ?? null); - this.selectedImageAssetId.set(product.primaryImage?.mediaAssetId ?? product.images[0]?.mediaAssetId ?? null); + this.selectedVariantId.set( + product.defaultVariant?.id ?? product.variants[0]?.id ?? null, + ); + this.selectedImageAssetId.set( + product.primaryImage?.mediaAssetId ?? + product.images[0]?.mediaAssetId ?? + null, + ); this.quantity.set(1); this.applySeo(product); if (product.model3d?.url && product.model3d.originalFilename) { - this.loadModelPreview(product.model3d.url, product.model3d.originalFilename); + this.loadModelPreview( + product.model3d.url, + product.model3d.originalFilename, + ); } else { this.modelFile.set(null); this.modelLoading.set(false); @@ -239,7 +250,9 @@ export class ProductDetailComponent { } priceLabel(): number { - return this.selectedVariant()?.priceChf ?? this.product()?.priceFromChf ?? 0; + return ( + this.selectedVariant()?.priceChf ?? this.product()?.priceFromChf ?? 0 + ); } colorLabel(variant: ShopProductVariantOption): string { @@ -282,7 +295,8 @@ export class ProductDetailComponent { product.seoDescription || product.excerpt || this.translate.instant('SHOP.CATALOG_META_DESCRIPTION'); - const robots = product.indexable === false ? 'noindex, nofollow' : 'index, follow'; + const robots = + product.indexable === false ? 'noindex, nofollow' : 'index, follow'; this.seoService.applyPageSeo({ title, diff --git a/frontend/src/app/features/shop/services/shop.service.spec.ts b/frontend/src/app/features/shop/services/shop.service.spec.ts index 69f5c8f..0c60c65 100644 --- a/frontend/src/app/features/shop/services/shop.service.spec.ts +++ b/frontend/src/app/features/shop/services/shop.service.spec.ts @@ -129,7 +129,9 @@ describe('ShopService', () => { it('posts add-to-cart with credentials and replaces local cart state', () => { service.addToCart('variant-red', 2).subscribe(); - const request = httpMock.expectOne('http://localhost:8000/api/shop/cart/items'); + const request = httpMock.expectOne( + 'http://localhost:8000/api/shop/cart/items', + ); expect(request.request.method).toBe('POST'); expect(request.request.withCredentials).toBeTrue(); expect(request.request.body).toEqual({ diff --git a/frontend/src/app/features/shop/services/shop.service.ts b/frontend/src/app/features/shop/services/shop.service.ts index c3bce2c..d5715cc 100644 --- a/frontend/src/app/features/shop/services/shop.service.ts +++ b/frontend/src/app/features/shop/services/shop.service.ts @@ -251,9 +251,12 @@ export class ShopService { params = params.set('featured', String(featured)); } - return this.http.get(`${this.apiUrl}/products`, { - params, - }); + return this.http.get( + `${this.apiUrl}/products`, + { + params, + }, + ); } getProduct(slug: string): Observable { @@ -337,10 +340,7 @@ export class ShopService { .pipe(tap((cart) => this.setCart(cart))); } - getProductModelFile( - urlOrPath: string, - filename: string, - ): Observable { + getProductModelFile(urlOrPath: string, filename: string): Observable { return this.http .get(this.resolveApiUrl(urlOrPath), { responseType: 'blob', diff --git a/frontend/src/app/features/shop/shop-page.component.html b/frontend/src/app/features/shop/shop-page.component.html index d7617c3..424bebd 100644 --- a/frontend/src/app/features/shop/shop-page.component.html +++ b/frontend/src/app/features/shop/shop-page.component.html @@ -4,20 +4,16 @@

{{ "SHOP.HERO_EYEBROW" | translate }}

- {{ - selectedCategory()?.name || ("SHOP.TITLE" | translate) - }} + {{ selectedCategory()?.name || ("SHOP.TITLE" | translate) }}

- {{ - selectedCategory()?.description || - ("SHOP.SUBTITLE" | translate) - }} + {{ selectedCategory()?.description || ("SHOP.SUBTITLE" | translate) }}

{{ selectedCategory() - ? ("SHOP.CATEGORY_META" | translate: { count: selectedCategory()?.productCount || 0 }) + ? ("SHOP.CATEGORY_META" + | translate: { count: selectedCategory()?.productCount || 0 }) : ("SHOP.CATALOG_META_DESCRIPTION" | translate) }}

@@ -75,7 +71,9 @@
-

{{ "SHOP.CATEGORY_PANEL_KICKER" | translate }}

+

+ {{ "SHOP.CATEGORY_PANEL_KICKER" | translate }} +

{{ "SHOP.CATEGORY_PANEL_TITLE" | translate }}

@@ -155,7 +153,9 @@