Skip to content

Commit 5612fb8

Browse files
committed
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
1 parent e761ef8 commit 5612fb8

File tree

5 files changed

+74
-9
lines changed

5 files changed

+74
-9
lines changed

server/router/fileserver/fileserver.go

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ const (
3636
var SupportedThumbnailMimeTypes = []string{
3737
"image/png",
3838
"image/jpeg",
39+
"image/heic",
40+
"image/heif",
41+
"image/webp",
3942
}
4043

4144
// FileServerService handles HTTP file serving with proper range request support.
@@ -143,6 +146,10 @@ func (s *FileServerService) serveAttachmentFile(c echo.Context) error {
143146
// Defense-in-depth: prevent embedding in frames and restrict content loading
144147
c.Response().Header().Set("X-Frame-Options", "DENY")
145148
c.Response().Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline';")
149+
// Support HDR/wide color gamut display for capable browsers
150+
if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") {
151+
c.Response().Header().Set("Color-Gamut", "srgb, p3, rec2020")
152+
}
146153

147154
// Force download for non-media files to prevent XSS execution
148155
if !strings.HasPrefix(contentType, "image/") &&
@@ -194,12 +201,15 @@ func (s *FileServerService) serveUserAvatar(c echo.Context) error {
194201
}
195202

196203
// Validate avatar MIME type to prevent XSS
204+
// Supports standard formats and HDR-capable formats
197205
allowedAvatarTypes := map[string]bool{
198206
"image/png": true,
199207
"image/jpeg": true,
200208
"image/jpg": true,
201209
"image/gif": true,
202210
"image/webp": true,
211+
"image/heic": true,
212+
"image/heif": true,
203213
}
204214
if !allowedAvatarTypes[imageType] {
205215
return echo.NewHTTPError(http.StatusBadRequest, "invalid avatar image type")
@@ -336,8 +346,16 @@ func (s *FileServerService) getCurrentUser(ctx context.Context, c echo.Context)
336346
}
337347

338348
// isImageType checks if the mime type is an image that supports thumbnails.
349+
// Supports standard formats (PNG, JPEG) and HDR-capable formats (HEIC, HEIF, WebP).
339350
func (*FileServerService) isImageType(mimeType string) bool {
340-
return mimeType == "image/png" || mimeType == "image/jpeg"
351+
supportedTypes := map[string]bool{
352+
"image/png": true,
353+
"image/jpeg": true,
354+
"image/heic": true,
355+
"image/heif": true,
356+
"image/webp": true,
357+
}
358+
return supportedTypes[mimeType]
341359
}
342360

343361
// getAttachmentReader returns a reader for the attachment content.

web/src/components/MemoView/components/metadata/AttachmentCard.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { cn } from "@/lib/utils";
22
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
3-
import { getAttachmentType, getAttachmentUrl } from "@/utils/attachment";
3+
import { getAttachmentType, getAttachmentUrl, getColorspace } from "@/utils/attachment";
44

55
interface AttachmentCardProps {
66
attachment: Attachment;
@@ -11,6 +11,7 @@ interface AttachmentCardProps {
1111
const AttachmentCard = ({ attachment, onClick, className }: AttachmentCardProps) => {
1212
const attachmentType = getAttachmentType(attachment);
1313
const sourceUrl = getAttachmentUrl(attachment);
14+
const colorspace = getColorspace(attachment.type);
1415

1516
if (attachmentType === "image/*") {
1617
return (
@@ -20,12 +21,21 @@ const AttachmentCard = ({ attachment, onClick, className }: AttachmentCardProps)
2021
className={cn("w-full h-full object-cover rounded-lg cursor-pointer", className)}
2122
onClick={onClick}
2223
loading="lazy"
24+
{...(colorspace && { colorSpace: colorspace as unknown as string })}
2325
/>
2426
);
2527
}
2628

2729
if (attachmentType === "video/*") {
28-
return <video src={sourceUrl} className={cn("w-full h-full object-cover rounded-lg", className)} controls preload="metadata" />;
30+
return (
31+
<video
32+
src={sourceUrl}
33+
className={cn("w-full h-full object-cover rounded-lg", className)}
34+
controls
35+
preload="metadata"
36+
{...(colorspace && { colorSpace: colorspace as unknown as string })}
37+
/>
38+
);
2939
}
3040

3141
return null;

web/src/components/MemoView/components/metadata/AttachmentList.tsx

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,18 +26,19 @@ function separateMediaAndDocs(attachments: Attachment[]): { media: Attachment[];
2626
}
2727

2828
const AttachmentList = ({ attachments }: AttachmentListProps) => {
29-
const [previewImage, setPreviewImage] = useState<{ open: boolean; urls: string[]; index: number }>({
29+
const [previewImage, setPreviewImage] = useState<{ open: boolean; urls: string[]; index: number; mimeType?: string }>({
3030
open: false,
3131
urls: [],
3232
index: 0,
33+
mimeType: undefined,
3334
});
3435

3536
const handleImageClick = (imgUrl: string, mediaAttachments: Attachment[]) => {
36-
const imgUrls = mediaAttachments
37-
.filter((attachment) => getAttachmentType(attachment) === "image/*")
38-
.map((attachment) => getAttachmentUrl(attachment));
37+
const imageAttachments = mediaAttachments.filter((attachment) => getAttachmentType(attachment) === "image/*");
38+
const imgUrls = imageAttachments.map((attachment) => getAttachmentUrl(attachment));
3939
const index = imgUrls.findIndex((url) => url === imgUrl);
40-
setPreviewImage({ open: true, urls: imgUrls, index });
40+
const mimeType = imageAttachments[index]?.type;
41+
setPreviewImage({ open: true, urls: imgUrls, index, mimeType });
4142
};
4243

4344
const { media: mediaItems, docs: docItems } = separateMediaAndDocs(attachments);
@@ -77,6 +78,7 @@ const AttachmentList = ({ attachments }: AttachmentListProps) => {
7778
onOpenChange={(open: boolean) => setPreviewImage((prev) => ({ ...prev, open }))}
7879
imgUrls={previewImage.urls}
7980
initialIndex={previewImage.index}
81+
mimeType={previewImage.mimeType}
8082
/>
8183
</>
8284
);

web/src/components/PreviewImageDialog.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,19 @@ import { X } from "lucide-react";
22
import React, { useEffect, useState } from "react";
33
import { Button } from "@/components/ui/button";
44
import { Dialog, DialogContent } from "@/components/ui/dialog";
5+
import { getColorspace } from "@/utils/attachment";
56

67
interface Props {
78
open: boolean;
89
onOpenChange: (open: boolean) => void;
910
imgUrls: string[];
1011
initialIndex?: number;
12+
mimeType?: string; // MIME type for HDR detection
1113
}
1214

13-
function PreviewImageDialog({ open, onOpenChange, imgUrls, initialIndex = 0 }: Props) {
15+
function PreviewImageDialog({ open, onOpenChange, imgUrls, initialIndex = 0, mimeType }: Props) {
1416
const [currentIndex, setCurrentIndex] = useState(initialIndex);
17+
const colorspace = mimeType ? getColorspace(mimeType) : undefined;
1518

1619
// Update current index when initialIndex prop changes
1720
useEffect(() => {
@@ -80,6 +83,7 @@ function PreviewImageDialog({ open, onOpenChange, imgUrls, initialIndex = 0 }: P
8083
draggable={false}
8184
loading="eager"
8285
decoding="async"
86+
{...(colorspace && { colorSpace: colorspace as unknown as string })}
8387
/>
8488
</div>
8589

web/src/utils/attachment.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,34 @@ export const isMidiFile = (mimeType: string): boolean => {
5252
const isPSD = (t: string) => {
5353
return t === "image/vnd.adobe.photoshop" || t === "image/x-photoshop" || t === "image/photoshop";
5454
};
55+
56+
// HDR-capable MIME types that support wide color gamut
57+
export const HDR_CAPABLE_FORMATS = [
58+
"image/heic",
59+
"image/heif",
60+
"image/webp",
61+
"image/png", // PNG can contain ICC profiles for wide gamut
62+
"image/jpeg", // JPEG can support extended color via profiles
63+
"video/mp4", // Can contain HDR tracks
64+
"video/quicktime", // Can contain HDR tracks
65+
"video/x-matroska", // Can contain HDR tracks
66+
"video/webm", // VP9 Profile 2 for HDR
67+
];
68+
69+
// isHDRCapable returns true if the MIME type supports HDR/wide color gamut.
70+
export const isHDRCapable = (mimeType: string): boolean => {
71+
return HDR_CAPABLE_FORMATS.some((format) => mimeType.startsWith(format));
72+
};
73+
74+
// getColorspace returns the appropriate colorspace attribute for wide gamut images.
75+
// Returns "display-p3" for HDR-capable formats, undefined for standard images.
76+
export const getColorspace = (mimeType: string): string | undefined => {
77+
return isHDRCapable(mimeType) ? "display-p3" : undefined;
78+
};
79+
80+
// supportsHDR checks if the browser supports wide color gamut display.
81+
// Uses CSS.supports() to detect color-gamut capability.
82+
export const supportsHDR = (): boolean => {
83+
if (typeof CSS === "undefined") return false;
84+
return CSS.supports("(color-gamut: srgb)") && CSS.supports("(color-gamut: p3)");
85+
};

0 commit comments

Comments
 (0)