diff --git a/server/router/fileserver/fileserver.go b/server/router/fileserver/fileserver.go
index bac7a46e1..c49d92e3a 100644
--- a/server/router/fileserver/fileserver.go
+++ b/server/router/fileserver/fileserver.go
@@ -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.
diff --git a/web/src/components/MemoView/components/metadata/AttachmentCard.tsx b/web/src/components/MemoView/components/metadata/AttachmentCard.tsx
index 2aadfb76c..1fae54fd3 100644
--- a/web/src/components/MemoView/components/metadata/AttachmentCard.tsx
+++ b/web/src/components/MemoView/components/metadata/AttachmentCard.tsx
@@ -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 ;
+ return (
+
+ );
}
return null;
diff --git a/web/src/components/MemoView/components/metadata/AttachmentList.tsx b/web/src/components/MemoView/components/metadata/AttachmentList.tsx
index a5b793e1e..522072790 100644
--- a/web/src/components/MemoView/components/metadata/AttachmentList.tsx
+++ b/web/src/components/MemoView/components/metadata/AttachmentList.tsx
@@ -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}
/>
>
);
diff --git a/web/src/components/PreviewImageDialog.tsx b/web/src/components/PreviewImageDialog.tsx
index 4787c892e..9bcf424ca 100644
--- a/web/src/components/PreviewImageDialog.tsx
+++ b/web/src/components/PreviewImageDialog.tsx
@@ -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 })}
/>
diff --git a/web/src/utils/attachment.ts b/web/src/utils/attachment.ts
index 5f3f1c64e..d78f04cf5 100644
--- a/web/src/utils/attachment.ts
+++ b/web/src/utils/attachment.ts
@@ -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)");
+};