fix: HEIF/HEIC-Fotos (Apple) für Beiträge und Storys annehmen

- 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 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-01 20:26:24 +02:00
1 parent 8f8fc8d75e
commit 1bf0f9282a
12 files changed
+201 -19

No files matched your search

+6 -3
View File
@@ -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",
+6 -3
View File
@@ -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",
+11 -4
View File
@@ -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;
+7
View File
@@ -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.
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+31
View File
@@ -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);
});
});
+11 -2
View File
@@ -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 = (
<input
type="file"
accept="image/jpeg,image/png,image/webp"
accept={`image/jpeg,image/png,image/webp,${HEIC_ACCEPT}`}
className="hidden"
ref={fileInputRef}
onChange={handleFileChange}
+12 -2
View File
@@ -1,6 +1,7 @@
"use client";
import { useState, useRef, useCallback } from "react";
import { HEIC_ACCEPT, HeicUnsupportedError, normalizeImageFile } from "@/lib/heic-to-jpeg";
import { ChevronDown, Loader2, ImagePlus, Camera } from "lucide-react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
@@ -82,10 +83,19 @@ export function MilestoneForm({ onClose }: MilestoneFormProps) {
);
const handleFile = useCallback(
async (file: File) => {
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) {
</label>
<input
type="file"
accept="image/jpeg,image/png,image/webp"
accept={`image/jpeg,image/png,image/webp,${HEIC_ACCEPT}`}
className="hidden"
ref={fileInputRef}
onChange={handleFileChange}
@@ -1,6 +1,7 @@
"use client";
import { useState, useRef, useCallback, useEffect } from "react";
import { HEIC_ACCEPT, HeicUnsupportedError, normalizeImageFile } from "@/lib/heic-to-jpeg";
import { ImagePlus, X, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { useTRPC } from "@/trpc/client";
@@ -78,7 +79,25 @@ export function MultiImageUpload({
);
const uploadFile = useCallback(
async (file: File, index: number) => {
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 */}
<input
type="file"
accept="image/jpeg,image/png,image/webp"
accept={`image/jpeg,image/png,image/webp,${HEIC_ACCEPT}`}
multiple
className="hidden"
ref={fileInputRef}
+11 -1
View File
@@ -1,6 +1,7 @@
"use client";
import { useState, useRef, useCallback } from "react";
import { HeicUnsupportedError, normalizeImageFile } from "@/lib/heic-to-jpeg";
import { Camera, Loader2, ImagePlus, Video, Music, Play, Pause, X } from "lucide-react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
@@ -200,13 +201,22 @@ export function StoryForm({ onClose }: StoryFormProps) {
);
const handleFile = useCallback(
async (file: File) => {
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
+81
View File
@@ -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<ImageBitmap | HTMLImageElement> {
if (typeof createImageBitmap === "function") {
try {
return await createImageBitmap(file);
} catch {
// weiter mit <img>, 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<File> {
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<Blob | null>((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<File> {
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";