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:
1 parent
8f8fc8d75e
commit
1bf0f9282a
12 files changed
+201
-19
No files matched your search
+6
-3
@@ -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
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
Reference in new issue
Block a user