feat: add HDR image and video support

- Add HDR detection utilities for wide color gamut formats (HEIC, HEIF, WebP)
- Apply colorSpace attribute to image/video elements for HDR-capable files
- Update frontend components (AttachmentCard, PreviewImageDialog, AttachmentList)
- Expand backend thumbnail generation to support HEIC, HEIF, WebP formats
- Add Color-Gamut response headers to advertise wide gamut support
- Extend avatar MIME type validation for HDR formats

Supported formats:
- Images: HEIC, HEIF, WebP, PNG, JPEG
- Videos: MP4, QuickTime, Matroska, WebM (VP9 Profile 2)

Browser support:
- Safari 14.1+, Chrome 118+, Edge 118+
- Gracefully degrades to sRGB on unsupported browsers
This commit is contained in:
Johnny 2026-01-03 13:16:56 +08:00
parent e761ef8684
commit 5612fb8f41
5 changed files with 74 additions and 9 deletions

View File

@ -36,6 +36,9 @@ const (
var SupportedThumbnailMimeTypes = []string{
"image/png",
"image/jpeg",
"image/heic",
"image/heif",
"image/webp",
}
// FileServerService handles HTTP file serving with proper range request support.
@ -143,6 +146,10 @@ func (s *FileServerService) serveAttachmentFile(c echo.Context) error {
// Defense-in-depth: prevent embedding in frames and restrict content loading
c.Response().Header().Set("X-Frame-Options", "DENY")
c.Response().Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline';")
// Support HDR/wide color gamut display for capable browsers
if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") {
c.Response().Header().Set("Color-Gamut", "srgb, p3, rec2020")
}
// Force download for non-media files to prevent XSS execution
if !strings.HasPrefix(contentType, "image/") &&
@ -194,12 +201,15 @@ func (s *FileServerService) serveUserAvatar(c echo.Context) error {
}
// Validate avatar MIME type to prevent XSS
// Supports standard formats and HDR-capable formats
allowedAvatarTypes := map[string]bool{
"image/png": true,
"image/jpeg": true,
"image/jpg": true,
"image/gif": true,
"image/webp": true,
"image/heic": true,
"image/heif": true,
}
if !allowedAvatarTypes[imageType] {
return echo.NewHTTPError(http.StatusBadRequest, "invalid avatar image type")
@ -336,8 +346,16 @@ func (s *FileServerService) getCurrentUser(ctx context.Context, c echo.Context)
}
// isImageType checks if the mime type is an image that supports thumbnails.
// Supports standard formats (PNG, JPEG) and HDR-capable formats (HEIC, HEIF, WebP).
func (*FileServerService) isImageType(mimeType string) bool {
return mimeType == "image/png" || mimeType == "image/jpeg"
supportedTypes := map[string]bool{
"image/png": true,
"image/jpeg": true,
"image/heic": true,
"image/heif": true,
"image/webp": true,
}
return supportedTypes[mimeType]
}
// getAttachmentReader returns a reader for the attachment content.

View File

@ -1,6 +1,6 @@
import { cn } from "@/lib/utils";
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
import { getAttachmentType, getAttachmentUrl } from "@/utils/attachment";
import { getAttachmentType, getAttachmentUrl, getColorspace } from "@/utils/attachment";
interface AttachmentCardProps {
attachment: Attachment;
@ -11,6 +11,7 @@ interface AttachmentCardProps {
const AttachmentCard = ({ attachment, onClick, className }: AttachmentCardProps) => {
const attachmentType = getAttachmentType(attachment);
const sourceUrl = getAttachmentUrl(attachment);
const colorspace = getColorspace(attachment.type);
if (attachmentType === "image/*") {
return (
@ -20,12 +21,21 @@ const AttachmentCard = ({ attachment, onClick, className }: AttachmentCardProps)
className={cn("w-full h-full object-cover rounded-lg cursor-pointer", className)}
onClick={onClick}
loading="lazy"
{...(colorspace && { colorSpace: colorspace as unknown as string })}
/>
);
}
if (attachmentType === "video/*") {
return <video src={sourceUrl} className={cn("w-full h-full object-cover rounded-lg", className)} controls preload="metadata" />;
return (
<video
src={sourceUrl}
className={cn("w-full h-full object-cover rounded-lg", className)}
controls
preload="metadata"
{...(colorspace && { colorSpace: colorspace as unknown as string })}
/>
);
}
return null;

View File

@ -26,18 +26,19 @@ function separateMediaAndDocs(attachments: Attachment[]): { media: Attachment[];
}
const AttachmentList = ({ attachments }: AttachmentListProps) => {
const [previewImage, setPreviewImage] = useState<{ open: boolean; urls: string[]; index: number }>({
const [previewImage, setPreviewImage] = useState<{ open: boolean; urls: string[]; index: number; mimeType?: string }>({
open: false,
urls: [],
index: 0,
mimeType: undefined,
});
const handleImageClick = (imgUrl: string, mediaAttachments: Attachment[]) => {
const imgUrls = mediaAttachments
.filter((attachment) => getAttachmentType(attachment) === "image/*")
.map((attachment) => getAttachmentUrl(attachment));
const imageAttachments = mediaAttachments.filter((attachment) => getAttachmentType(attachment) === "image/*");
const imgUrls = imageAttachments.map((attachment) => getAttachmentUrl(attachment));
const index = imgUrls.findIndex((url) => url === imgUrl);
setPreviewImage({ open: true, urls: imgUrls, index });
const mimeType = imageAttachments[index]?.type;
setPreviewImage({ open: true, urls: imgUrls, index, mimeType });
};
const { media: mediaItems, docs: docItems } = separateMediaAndDocs(attachments);
@ -77,6 +78,7 @@ const AttachmentList = ({ attachments }: AttachmentListProps) => {
onOpenChange={(open: boolean) => setPreviewImage((prev) => ({ ...prev, open }))}
imgUrls={previewImage.urls}
initialIndex={previewImage.index}
mimeType={previewImage.mimeType}
/>
</>
);

View File

@ -2,16 +2,19 @@ import { X } from "lucide-react";
import React, { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { getColorspace } from "@/utils/attachment";
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
imgUrls: string[];
initialIndex?: number;
mimeType?: string; // MIME type for HDR detection
}
function PreviewImageDialog({ open, onOpenChange, imgUrls, initialIndex = 0 }: Props) {
function PreviewImageDialog({ open, onOpenChange, imgUrls, initialIndex = 0, mimeType }: Props) {
const [currentIndex, setCurrentIndex] = useState(initialIndex);
const colorspace = mimeType ? getColorspace(mimeType) : undefined;
// Update current index when initialIndex prop changes
useEffect(() => {
@ -80,6 +83,7 @@ function PreviewImageDialog({ open, onOpenChange, imgUrls, initialIndex = 0 }: P
draggable={false}
loading="eager"
decoding="async"
{...(colorspace && { colorSpace: colorspace as unknown as string })}
/>
</div>

View File

@ -52,3 +52,34 @@ export const isMidiFile = (mimeType: string): boolean => {
const isPSD = (t: string) => {
return t === "image/vnd.adobe.photoshop" || t === "image/x-photoshop" || t === "image/photoshop";
};
// HDR-capable MIME types that support wide color gamut
export const HDR_CAPABLE_FORMATS = [
"image/heic",
"image/heif",
"image/webp",
"image/png", // PNG can contain ICC profiles for wide gamut
"image/jpeg", // JPEG can support extended color via profiles
"video/mp4", // Can contain HDR tracks
"video/quicktime", // Can contain HDR tracks
"video/x-matroska", // Can contain HDR tracks
"video/webm", // VP9 Profile 2 for HDR
];
// isHDRCapable returns true if the MIME type supports HDR/wide color gamut.
export const isHDRCapable = (mimeType: string): boolean => {
return HDR_CAPABLE_FORMATS.some((format) => mimeType.startsWith(format));
};
// getColorspace returns the appropriate colorspace attribute for wide gamut images.
// Returns "display-p3" for HDR-capable formats, undefined for standard images.
export const getColorspace = (mimeType: string): string | undefined => {
return isHDRCapable(mimeType) ? "display-p3" : undefined;
};
// supportsHDR checks if the browser supports wide color gamut display.
// Uses CSS.supports() to detect color-gamut capability.
export const supportsHDR = (): boolean => {
if (typeof CSS === "undefined") return false;
return CSS.supports("(color-gamut: srgb)") && CSS.supports("(color-gamut: p3)");
};