mirror of https://github.com/usememos/memos.git
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:
parent
e761ef8684
commit
5612fb8f41
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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)");
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue