From 1bf0f9282a48aa6cdf1fa199a8542a71d0c50a0b Mon Sep 17 00:00:00 2001 From: neodk2004 Date: Thu, 1 Oct 2026 20:26:24 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20HEIF/HEIC-Fotos=20(Apple)=20f=C3=BCr=20B?= =?UTF-8?q?eitr=C3=A4ge=20und=20Storys=20annehmen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - App: post-new.tsx sortierte HEIC/HEIF still aus, obwohl resizeForUpload() ohnehin in JPEG umwandelt — jetzt erlaubt, plus Hinweis bei übersprungenen Fotos - Web: neues src/lib/heic-to-jpeg.ts wandelt HEIC vor dem Upload im Browser in JPEG um (Safari kann HEIC nativ dekodieren); eingebaut in Beiträge, Storys, Meilensteine und Tierbild, accept um HEIC ergänzt. Browser ohne HEIC-Dekodierung bekommen eine klare Meldung statt "falsches Format". - Server-Whitelist (JPEG/PNG/WebP) bleibt unverändert Co-Authored-By: Claude Opus 5.5 --- messages/de.json | 9 ++- messages/en.json | 9 ++- mobile/app/(app)/post-new.tsx | 15 +++- mobile/found.md | 7 ++ mobile/src/i18n/locales/de.json | 3 +- mobile/src/i18n/locales/en.json | 3 +- src/__tests__/heic-to-jpeg.test.ts | 31 +++++++ src/components/pet/AvatarUpload.tsx | 13 ++- .../post-creation/MilestoneForm.tsx | 14 +++- .../post-creation/MultiImageUpload.tsx | 23 +++++- src/components/stories/StoryForm.tsx | 12 ++- src/lib/heic-to-jpeg.ts | 81 +++++++++++++++++++ 12 files changed, 201 insertions(+), 19 deletions(-) create mode 100644 src/__tests__/heic-to-jpeg.test.ts create mode 100644 src/lib/heic-to-jpeg.ts diff --git a/messages/de.json b/messages/de.json index 1870f9b..13b129f 100644 --- a/messages/de.json +++ b/messages/de.json @@ -116,7 +116,8 @@ "zoom": "Zoom", "cropCancel": "Abbrechen", "cropSave": "Übernehmen", - "cropSaving": "Wird verarbeitet…" + "cropSaving": "Wird verarbeitet…", + "heicUnsupported": "Dieser Browser kann HEIC-Fotos nicht lesen. Öffne PawFeed in Safari oder speichere das Foto als JPG." }, "Onboarding": { "addFirstPet": "Füge dein erstes Tier hinzu", @@ -510,7 +511,8 @@ "videoTooLong": "Story-Videos dürfen höchstens {max} Sekunden lang sein.", "videoUnreadable": "Videolänge konnte nicht gelesen werden — bitte versuch eine andere Datei.", "musicLabel": "Musik hinzufügen (optional)", - "muteOriginalLabel": "Original-Ton stummschalten, stattdessen Musik abspielen" + "muteOriginalLabel": "Original-Ton stummschalten, stattdessen Musik abspielen", + "heicUnsupported": "Dieser Browser kann HEIC-Fotos nicht lesen. Öffne PawFeed in Safari oder speichere das Foto als JPG." }, "MilestoneForm": { "questionLabel": "Was ist der Meilenstein?", @@ -530,7 +532,8 @@ "errorToast": "Meilenstein konnte nicht geteilt werden. Bitte versuch es erneut.", "fileTooLarge": "Foto muss unter 10 MB sein.", "invalidType": "Lade ein JPG-, PNG- oder WebP-Bild hoch.", - "uploadFailed": "Upload fehlgeschlagen — bitte versuch es erneut." + "uploadFailed": "Upload fehlgeschlagen — bitte versuch es erneut.", + "heicUnsupported": "Dieser Browser kann HEIC-Fotos nicht lesen. Öffne PawFeed in Safari oder speichere das Foto als JPG." }, "MilestoneTypes": { "BIRTHDAY": "Geburtstag", diff --git a/messages/en.json b/messages/en.json index 9dcb179..7e5d810 100644 --- a/messages/en.json +++ b/messages/en.json @@ -116,7 +116,8 @@ "zoom": "Zoom", "cropCancel": "Cancel", "cropSave": "Apply", - "cropSaving": "Processing…" + "cropSaving": "Processing…", + "heicUnsupported": "This browser cannot read HEIC photos. Open PawFeed in Safari or save the photo as JPG." }, "Onboarding": { "addFirstPet": "Add your first pet", @@ -510,7 +511,8 @@ "videoTooLong": "Story videos can be at most {max} seconds long.", "videoUnreadable": "Couldn't read the video length — please try a different file.", "musicLabel": "Add music (optional)", - "muteOriginalLabel": "Mute original audio, play this track instead" + "muteOriginalLabel": "Mute original audio, play this track instead", + "heicUnsupported": "This browser cannot read HEIC photos. Open PawFeed in Safari or save the photo as JPG." }, "MilestoneForm": { "questionLabel": "What's the milestone?", @@ -530,7 +532,8 @@ "errorToast": "Couldn't share this milestone. Please try again.", "fileTooLarge": "Photo must be under 10 MB.", "invalidType": "Upload a JPG, PNG, or WebP image.", - "uploadFailed": "Upload failed — please try again." + "uploadFailed": "Upload failed — please try again.", + "heicUnsupported": "This browser cannot read HEIC photos. Open PawFeed in Safari or save the photo as JPG." }, "MilestoneTypes": { "BIRTHDAY": "Birthday", diff --git a/mobile/app/(app)/post-new.tsx b/mobile/app/(app)/post-new.tsx index feacbcb..18da4ff 100644 --- a/mobile/app/(app)/post-new.tsx +++ b/mobile/app/(app)/post-new.tsx @@ -14,7 +14,10 @@ import { TextField } from "../../src/components/TextField"; import { Button } from "../../src/components/Button"; import { radii, getTypography, type ColorTokens } from "../../src/theme/tokens"; -const ALLOWED_MIME_TYPES = ["image/jpeg", "image/png", "image/webp"]; +// HEIC/HEIF (iPhone, manche Android-Kameras) ist hier ausdrücklich erlaubt: +// resizeForUpload() wandelt jedes Bild vor dem Upload in JPEG um, der Server +// sieht also nie HEIC. Vorher wurden solche Fotos hier still aussortiert. +const ALLOWED_MIME_TYPES = ["image/jpeg", "image/png", "image/webp", "image/heic", "image/heif"]; const MAX_IMAGES = 10; type PickedImage = { @@ -92,9 +95,13 @@ export default function NewPostScreen() { return; } - const accepted = assets - .filter((asset) => ALLOWED_MIME_TYPES.includes(asset.mimeType ?? "image/jpeg")) - .slice(0, available); + const supported = assets.filter((asset) => + ALLOWED_MIME_TYPES.includes((asset.mimeType ?? "image/jpeg").toLowerCase()) + ); + if (supported.length < assets.length) { + Alert.alert(t("post.unsupportedFormat")); + } + const accepted = supported.slice(0, available); if (accepted.length === 0) return; diff --git a/mobile/found.md b/mobile/found.md index e198a9f..6459aee 100644 --- a/mobile/found.md +++ b/mobile/found.md @@ -145,4 +145,11 @@ Neu 7: !!! iOS - Founds beginnen hier - nicht mit Android vertauschen !!! +- [x] Nutzer-Feedback: Storys und Beiträge mit Bildern im HEIF/HEIC-Format (Apple) lassen sich nicht erstellen. + → **Kein reines Serverproblem.** Der Server nimmt bewusst nur JPEG/PNG/WebP an (`ALLOWED_CONTENT_TYPES` in `src/lib/supabase-storage.ts`), und die Bytes gehen direkt vom Client zu Supabase — umgewandelt wurde HEIC nirgends. Zwei Fundstellen: + 1. **App, Beiträge:** `app/(app)/post-new.tsx` filterte gewählte Fotos nach ihrem *Original*-MIME-Typ (`image/jpeg|png|webp`) und warf `image/heic`/`image/heif` still raus — obwohl `resizeForUpload()` danach sowieso jedes Bild in JPEG umwandelt. Storys/Meilensteine/Avatar in der App filtern nicht und waren nicht betroffen. + 2. **Web, alle Bild-Uploads** (Beiträge, Storys, Meilensteine, Tierbild): nur JPEG/PNG/WebP erlaubt; ein `.heic` vom Mac oder eine rohe HEIC-Datei aus iOS-Safari (Storys nutzen `accept="image/*,video/*"`, das Safaris automatische HEIC→JPEG-Umwandlung aushebelt) wurde abgelehnt. + Fix: App — HEIC/HEIF in `post-new.tsx` zugelassen, plus Hinweis-Alert, falls doch einmal Bilder übersprungen werden (`post.unsupportedFormat`). Web — neues `src/lib/heic-to-jpeg.ts` wandelt HEIC vor dem Upload per Browser-Dekodierung (Canvas → JPEG 0,9) um, eingebaut in `MultiImageUpload`, `StoryForm`, `MilestoneForm`, `AvatarUpload`; `accept` um HEIC ergänzt, damit der Mac-Dateidialog sie anbietet. Bewusst **ohne** Zusatzbibliothek (heic-to: LGPL-3.0, ~26 MB; heic2any: seit 2023 ungepflegt): Safari (iOS 17+/macOS 14+) dekodiert HEIC selbst; Browser ohne HEIC-Support (z. B. Chrome/Windows) bekommen eine klare Meldung „Öffne PawFeed in Safari oder speichere das Foto als JPG“ (`heicUnsupported`). Tests in `src/__tests__/heic-to-jpeg.test.ts`, alle 266 Tests + Build grün. Nicht auf einem echten iPhone getestet. + + diff --git a/mobile/src/i18n/locales/de.json b/mobile/src/i18n/locales/de.json index df8f9f2..7d6f4a6 100644 --- a/mobile/src/i18n/locales/de.json +++ b/mobile/src/i18n/locales/de.json @@ -84,7 +84,8 @@ "shareButton": "Teilen", "deleteConfirmTitle": "Beitrag wirklich löschen?", "deleteConfirmMessage": "Dieser Beitrag wird unwiderruflich gelöscht.", - "deleteError": "Beitrag konnte nicht gelöscht werden. Bitte versuch es erneut." + "deleteError": "Beitrag konnte nicht gelöscht werden. Bitte versuch es erneut.", + "unsupportedFormat": "Einige Fotos haben ein Format, das PawFeed nicht verarbeiten kann, und wurden übersprungen." }, "story": { "changePhotoHint": "Antippen, um ein anderes Foto zu wählen", diff --git a/mobile/src/i18n/locales/en.json b/mobile/src/i18n/locales/en.json index 3f5e4d5..e78e01c 100644 --- a/mobile/src/i18n/locales/en.json +++ b/mobile/src/i18n/locales/en.json @@ -84,7 +84,8 @@ "shareButton": "Share", "deleteConfirmTitle": "Delete this post?", "deleteConfirmMessage": "This post will be permanently deleted.", - "deleteError": "Couldn't delete this post. Please try again." + "deleteError": "Couldn't delete this post. Please try again.", + "unsupportedFormat": "Some photos use a format PawFeed cannot process and were skipped." }, "story": { "changePhotoHint": "Tap to choose a different photo", diff --git a/src/__tests__/heic-to-jpeg.test.ts b/src/__tests__/heic-to-jpeg.test.ts new file mode 100644 index 0000000..c0eb735 --- /dev/null +++ b/src/__tests__/heic-to-jpeg.test.ts @@ -0,0 +1,31 @@ +/** + * HEIC/HEIF-Erkennung und -Umwandlung vor dem Upload (src/lib/heic-to-jpeg.ts). + * jsdom kann keine Bilder dekodieren — der Fehlerpfad steht damit für + * "Browser ohne HEIC-Unterstützung" (z. B. Chrome unter Windows). + */ + +import { describe, it, expect } from "vitest"; +import { HeicUnsupportedError, isHeicFile, normalizeImageFile } from "@/lib/heic-to-jpeg"; + +const file = (name: string, type: string) => new File([new Uint8Array([1, 2, 3])], name, { type }); + +describe("isHeicFile", () => { + it("detects HEIC/HEIF by MIME type or by extension when the type is empty", () => { + expect(isHeicFile(file("IMG_0001.HEIC", "image/heic"))).toBe(true); + expect(isHeicFile(file("photo.heif", "image/heif"))).toBe(true); + expect(isHeicFile(file("IMG_0002.heic", ""))).toBe(true); + expect(isHeicFile(file("photo.jpg", "image/jpeg"))).toBe(false); + expect(isHeicFile(file("photo.png", "image/png"))).toBe(false); + }); +}); + +describe("normalizeImageFile", () => { + it("passes JPEG/PNG/WebP through unchanged", async () => { + const jpeg = file("photo.jpg", "image/jpeg"); + expect(await normalizeImageFile(jpeg)).toBe(jpeg); + }); + + it("reports HeicUnsupportedError when the browser cannot decode HEIC", async () => { + await expect(normalizeImageFile(file("IMG_0001.HEIC", "image/heic"))).rejects.toBeInstanceOf(HeicUnsupportedError); + }); +}); diff --git a/src/components/pet/AvatarUpload.tsx b/src/components/pet/AvatarUpload.tsx index fd324f5..51a1c4f 100644 --- a/src/components/pet/AvatarUpload.tsx +++ b/src/components/pet/AvatarUpload.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useRef, useCallback } from "react"; +import { HEIC_ACCEPT, HeicUnsupportedError, normalizeImageFile } from "@/lib/heic-to-jpeg"; import { useTranslations } from "next-intl"; import { Camera, Loader2 } from "lucide-react"; import { toast } from "sonner"; @@ -142,11 +143,19 @@ export function AvatarUpload({ // Validates a freshly picked/dropped file and opens the crop dialog — // the actual upload only fires once the user confirms a crop. const selectFile = useCallback( - (file: File) => { + async (originalFile: File) => { if (!petId) { toast.info(t("savePetFirst")); return; } + // Apple HEIC/HEIF → JPEG im Browser (Server nimmt nur JPEG/PNG/WebP an) + let file: File; + try { + file = await normalizeImageFile(originalFile); + } catch (err) { + handleError(err instanceof HeicUnsupportedError ? t("heicUnsupported") : t("invalidType")); + return; + } if (file.size > MAX_FILE_SIZE) { handleError(t("fileTooLarge")); return; @@ -226,7 +235,7 @@ export function AvatarUpload({ const fileInput = ( { + async (originalFile: File) => { if (!activePetId) return; setUploadError(null); + // Apple HEIC/HEIF → JPEG im Browser (Server nimmt nur JPEG/PNG/WebP an) + let file: File; + try { + file = await normalizeImageFile(originalFile); + } catch (err) { + setUploadError(err instanceof HeicUnsupportedError ? t("heicUnsupported") : t("invalidType")); + return; + } + if (file.size > MAX_FILE_SIZE) { setUploadError(t("fileTooLarge")); return; @@ -229,7 +239,7 @@ export function MilestoneForm({ onClose }: MilestoneFormProps) { { + async (originalFile: File, index: number) => { + // Apple HEIC/HEIF → JPEG im Browser (Server nimmt nur JPEG/PNG/WebP an) + let file: File; + try { + file = await normalizeImageFile(originalFile); + } catch (err) { + setImages((prev) => { + const updated = [...prev]; + updated[index] = { ...updated[index], status: "error" }; + return updated; + }); + toast.error( + err instanceof HeicUnsupportedError + ? "This browser cannot read HEIC photos. Please use Safari or export the photo as JPG." + : "Upload a JPG, PNG, or WebP image." + ); + return null; + } + // Validate size if (file.size > MAX_FILE_SIZE) { setImages((prev) => { @@ -274,7 +293,7 @@ export function MultiImageUpload({ {/* Hidden file input */} { + async (originalFile: File) => { if (!activePetId) { toast.error(t("noPetSelected")); return; } setErrorMessage(null); + // Apple HEIC/HEIF → JPEG im Browser (siehe ACCEPT_ATTR-Kommentar oben: + // image/* lässt rohe HEIC-Dateien durch, der Server nimmt sie nicht an) + let file: File; + try { + file = await normalizeImageFile(originalFile); + } catch (err) { + setErrorMessage(err instanceof HeicUnsupportedError ? t("heicUnsupported") : t("invalidType")); + return; + } // Strip codec parameters before comparing — some Android camera-roll // pickers report e.g. `video/mp4;codecs=hvc1` for HEVC-recorded clips, // which never matched the bare `ALLOWED_VIDEO_TYPES` strings. This was diff --git a/src/lib/heic-to-jpeg.ts b/src/lib/heic-to-jpeg.ts new file mode 100644 index 0000000..eed5e3f --- /dev/null +++ b/src/lib/heic-to-jpeg.ts @@ -0,0 +1,81 @@ +/** + * Apple-Fotos im HEIF/HEIC-Format vor dem Upload in JPEG umwandeln. + * + * Der Server (ALLOWED_CONTENT_TYPES in src/lib/supabase-storage.ts) nimmt nur + * JPEG/PNG/WebP an, und die Bytes gehen direkt vom Browser zu Supabase — eine + * Umwandlung kann also nur hier im Client passieren. Ohne Zusatzbibliothek: + * Safari (iOS 17+, macOS 14+) dekodiert HEIC selbst, das deckt iPhone- und + * Mac-Nutzer ab. Kann der Browser das Bild nicht lesen (z. B. Chrome unter + * Windows), wirft convertHeicToJpeg einen HeicUnsupportedError, damit die UI + * eine verständliche Meldung statt "falsches Format" zeigen kann. + */ + +const HEIC_TYPES = new Set(["image/heic", "image/heif", "image/heic-sequence", "image/heif-sequence"]); +const HEIC_EXTENSION = /\.(heic|heif)$/i; +const JPEG_QUALITY = 0.9; + +export class HeicUnsupportedError extends Error { + constructor() { + super("HEIC_UNSUPPORTED"); + this.name = "HeicUnsupportedError"; + } +} + +/** Manche Browser liefern für .heic einen leeren file.type — dann zählt die Endung. */ +export function isHeicFile(file: File): boolean { + return HEIC_TYPES.has(file.type.toLowerCase()) || HEIC_EXTENSION.test(file.name); +} + +async function decode(file: File): Promise { + if (typeof createImageBitmap === "function") { + try { + return await createImageBitmap(file); + } catch { + // weiter mit , das in manchen Safari-Versionen mehr Formate kann + } + } + const url = URL.createObjectURL(file); + try { + const img = new Image(); + img.src = url; + await img.decode(); + return img; + } finally { + URL.revokeObjectURL(url); + } +} + +export async function convertHeicToJpeg(file: File): Promise { + let source: ImageBitmap | HTMLImageElement; + try { + source = await decode(file); + } catch { + throw new HeicUnsupportedError(); + } + + const width = "naturalWidth" in source ? source.naturalWidth : source.width; + const height = "naturalHeight" in source ? source.naturalHeight : source.height; + if (!width || !height) throw new HeicUnsupportedError(); + + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext("2d"); + if (!ctx) throw new HeicUnsupportedError(); + ctx.drawImage(source, 0, 0); + if ("close" in source) source.close(); + + const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/jpeg", JPEG_QUALITY)); + if (!blob) throw new HeicUnsupportedError(); + + const name = HEIC_EXTENSION.test(file.name) ? file.name.replace(HEIC_EXTENSION, ".jpg") : `${file.name}.jpg`; + return new File([blob], name, { type: "image/jpeg", lastModified: file.lastModified }); +} + +/** Gibt HEIC-Dateien als JPEG zurück, alle anderen unverändert. */ +export async function normalizeImageFile(file: File): Promise { + return isHeicFile(file) ? convertHeicToJpeg(file) : file; +} + +/** accept-Attribut-Ergänzung, damit Datei-Dialoge (v. a. macOS) HEIC-Dateien überhaupt anbieten. */ +export const HEIC_ACCEPT = "image/heic,image/heif,.heic,.heif";