Viewer: Set native video stream src based on mimetype #1307 #3168 #4698

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer 2025-01-27 13:21:05 +01:00
parent 8c3ec7435e
commit 420fa9946c
37 changed files with 770 additions and 393 deletions

39
pkg/clean/content_type.go Normal file
View file

@ -0,0 +1,39 @@
package clean
import (
"github.com/photoprism/photoprism/pkg/header"
)
// ContentType normalizes media content type strings, see https://en.wikipedia.org/wiki/Media_type.
func ContentType(s string) string {
if s == "" {
return header.ContentTypeBinary
}
s = Type(s)
switch s {
case "":
return header.ContentTypeBinary
case "text/json", "application/json":
return header.ContentTypeJsonUtf8
case "text/htm", "text/html":
return header.ContentTypeHtml
case "text/plain":
return header.ContentTypeText
case "text/pdf", "text/x-pdf", "application/x-pdf", "application/acrobat":
return header.ContentTypePDF
case "image/svg":
return header.ContentTypeSVG
case "image/jpe", "image/jpg":
return header.ContentTypeJPEG
case "video/mp4; codecs=\"avc\"":
return header.ContentTypeAVC
case "video/mp4; codecs=\"hvc1\"", "video/mp4; codecs=\"hvc\"", "video/mp4; codecs=\"hevc\"":
return header.ContentTypeHEVC
case "video/webm; codecs=\"av01\"":
return header.ContentTypeAV1
}
return s
}

View file

@ -31,8 +31,10 @@ func Log(s string) string {
case ' ':
spaces = true
return r
case '`', '"':
case '`':
return '\''
case '"':
return '"'
case '\\', '$', '<', '>', '{', '}':
return '?'
default:

View file

@ -25,7 +25,7 @@ func TestLog(t *testing.T) {
assert.Equal(t, "?", Log("User-Agent: {jndi:ldap://<host>:<port>/<path>}"))
})
t.Run("SpecialChars", func(t *testing.T) {
assert.Equal(t, "' The ?quick? ''brown 'fox. '", Log(" The <quick>\n\r ''brown \"fox. \t "))
assert.Equal(t, "' The ?quick? ''brown \"fox. '", Log(" The <quick>\n\r ''brown \"fox. \t "))
})
t.Run("LoremIpsum", func(t *testing.T) {
assert.Equal(t, "'It is a long established fact that a reader will be distracted by the readable "+

View file

@ -29,11 +29,16 @@ const (
MimeTypeAI = "application/vnd.adobe.illustrator"
MimeTypePS = "application/postscript"
MimeTypeEPS = "image/eps"
MimeTypeText = "text/plain"
MimeTypeXML = "text/xml"
MimeTypeJSON = "application/json"
)
// MimeType returns the mime type of a file, or an empty string if it could not be detected.
// MimeType returns the mimetype of a file, or an empty string if it could not be determined.
//
// The IANA and IETF use the term "media type", and consider the term "MIME type" to be obsolete,
// since media types have become used in contexts unrelated to email, such as HTTP:
// https://en.wikipedia.org/wiki/Media_type#Structure
func MimeType(filename string) (mimeType string) {
if filename == "" {
return MimeTypeUnknown
@ -69,7 +74,7 @@ func MimeType(filename string) (mimeType string) {
detectedType, err := mimetype.DetectFile(filename)
if detectedType != nil && err == nil {
mimeType, _, _ = strings.Cut(detectedType.String(), ";")
mimeType = detectedType.String()
}
// Treat "application/octet-stream" as unknown.
@ -100,3 +105,23 @@ func MimeType(filename string) (mimeType string) {
return mimeType
}
// BaseType returns the media type string without any optional parameters.
func BaseType(mimeType string) string {
if mimeType == "" {
return ""
}
mimeType, _, _ = strings.Cut(mimeType, ";")
return strings.ToLower(mimeType)
}
// IsType tests if the specified mime types are matching, except for any optional parameters.
func IsType(mime1, mime2 string) bool {
if mime1 == mime2 {
return true
}
return BaseType(mime1) == BaseType(mime2)
}

View file

@ -77,3 +77,93 @@ func TestMimeType(t *testing.T) {
assert.Equal(t, "image/eps", mimeType)
})
}
func TestBaseType(t *testing.T) {
t.Run("MP4", func(t *testing.T) {
filename := Abs("./testdata/test.mp4")
mimeType := BaseType(MimeType(filename))
assert.Equal(t, "video/mp4", mimeType)
})
t.Run("MOV", func(t *testing.T) {
filename := Abs("./testdata/test.mov")
mimeType := BaseType(MimeType(filename))
assert.Equal(t, "video/quicktime", mimeType)
})
t.Run("JPEG", func(t *testing.T) {
filename := Abs("./testdata/test.jpg")
mimeType := BaseType(MimeType(filename))
assert.Equal(t, "image/jpeg", mimeType)
})
t.Run("InvalidFilename", func(t *testing.T) {
filename := Abs("./testdata/xxx.jpg")
mimeType := BaseType(MimeType(filename))
assert.Equal(t, "", mimeType)
})
t.Run("EmptyFilename", func(t *testing.T) {
mimeType := BaseType("")
assert.Equal(t, "", mimeType)
})
t.Run("AVIF", func(t *testing.T) {
filename := Abs("./testdata/test.avif")
mimeType := BaseType(MimeType(filename))
assert.Equal(t, "image/avif", mimeType)
})
t.Run("AVIFS", func(t *testing.T) {
filename := Abs("./testdata/test.avifs")
mimeType := MimeType(filename)
assert.Equal(t, "image/avif-sequence", mimeType)
})
t.Run("HEIC", func(t *testing.T) {
filename := Abs("./testdata/test.heic")
mimeType := BaseType(MimeType(filename))
assert.Equal(t, "image/heic", mimeType)
})
t.Run("HEICS", func(t *testing.T) {
filename := Abs("./testdata/test.heics")
mimeType := BaseType(MimeType(filename))
assert.Equal(t, "image/heic-sequence", mimeType)
})
t.Run("DNG", func(t *testing.T) {
filename := Abs("./testdata/test.dng")
mimeType := BaseType(MimeType(filename))
assert.Equal(t, "image/dng", mimeType)
})
t.Run("SVG", func(t *testing.T) {
filename := Abs("./testdata/test.svg")
mimeType := BaseType(MimeType(filename))
assert.Equal(t, "image/svg+xml", mimeType)
})
t.Run("AI", func(t *testing.T) {
filename := Abs("./testdata/test.ai")
mimeType := BaseType(MimeType(filename))
assert.Equal(t, "application/vnd.adobe.illustrator", mimeType)
})
t.Run("PS", func(t *testing.T) {
filename := Abs("./testdata/test.ps")
mimeType := BaseType(MimeType(filename))
assert.Equal(t, "application/postscript", mimeType)
})
t.Run("EPS", func(t *testing.T) {
filename := Abs("./testdata/test.eps")
mimeType := BaseType(MimeType(filename))
assert.Equal(t, "image/eps", mimeType)
})
}
func TestIsType(t *testing.T) {
t.Run("True", func(t *testing.T) {
assert.True(t, IsType("", MimeTypeUnknown))
assert.True(t, IsType("video/jpg", "video/jpg"))
assert.True(t, IsType("video/jpeg", "video/jpeg"))
assert.True(t, IsType("video/mp4", "video/mp4"))
assert.True(t, IsType("video/mp4", MimeTypeMP4))
assert.True(t, IsType("video/mp4", "video/MP4"))
assert.True(t, IsType("video/mp4", "video/MP4; codecs=\"avc1\""))
})
t.Run("False", func(t *testing.T) {
assert.False(t, IsType("", MimeTypeMP4))
assert.False(t, IsType("video/jpeg", "video/jpg"))
assert.False(t, IsType("video/mp4", MimeTypeUnknown))
assert.False(t, IsType(MimeTypeMP4, MimeTypeJPEG))
})
}

View file

@ -17,14 +17,22 @@ const (
// Standard ContentType header values.
const (
ContentTypeBinary = "application/octet-stream"
ContentTypeForm = "application/x-www-form-urlencoded"
ContentTypeMultipart = "multipart/form-data"
ContentTypeJson = "application/json"
ContentTypeJsonUtf8 = "application/json; charset=utf-8"
ContentTypeHtml = "text/html; charset=utf-8"
ContentTypeText = "text/plain; charset=utf-8"
ContentTypePDF = "application/pdf"
ContentTypePNG = "image/png"
ContentTypeJPEG = "image/jpeg"
ContentTypeSVG = "image/svg+xml"
ContentTypeAVC = "video/mp4; codecs=\"avc1\""
ContentTypeHEVC = "video/mp4; codecs=\"hvc1.1.6.L93.90\""
ContentTypeOGG = "video/ogg"
ContentTypeWebM = "video/webm"
ContentTypeVP8 = "video/webm; codecs=\"vp8\""
ContentTypeVP9 = "video/webm; codecs=\"vp9\""
ContentTypeAV1 = "video/webm; codecs=\"av01.0.08M.08\""
)

View file

@ -1,18 +1,13 @@
package video
type Codec string
// String returns the codec name as string.
func (c Codec) String() string {
return string(c)
}
// Check browser support: https://cconcolato.github.io/media-mime-support/
type Codec = string
// Video codecs supported by web browsers:
// https://cconcolato.github.io/media-mime-support/
const (
CodecUnknown Codec = ""
CodecAVC Codec = "avc1"
CodecHVC Codec = "hvc1"
CodecHEVC Codec = "hvc1"
CodecVVC Codec = "vvc"
CodecEVC Codec = "evc"
CodecAV1 Codec = "av01"
@ -34,11 +29,14 @@ var Codecs = StandardCodecs{
"iso/avc": CodecAVC,
"v_mpeg4/avc": CodecAVC,
"v_mpeg4/iso/avc": CodecAVC,
"hevc": CodecHVC,
"hvc": CodecHVC,
"hvc1": CodecHVC,
"v_hvc": CodecHVC,
"v_hvc1": CodecHVC,
"hevc": CodecHEVC,
"hevC": CodecHEVC,
"hvc": CodecHEVC,
"hvc1": CodecHEVC,
"v_hvc": CodecHEVC,
"v_hvc1": CodecHEVC,
"hev": CodecHEVC,
"hev1": CodecHEVC,
"evc": CodecEVC,
"evc1": CodecEVC,
"evcC": CodecEVC,

View file

@ -13,7 +13,7 @@ func TestContentType(t *testing.T) {
assert.Equal(t, fs.MimeTypeMOV, ContentType(fs.MimeTypeMOV, ""))
})
t.Run("QuickTime_HVC", func(t *testing.T) {
assert.Equal(t, `video/quicktime; codecs="hvc1"`, ContentType(fs.MimeTypeMOV, CodecHVC))
assert.Equal(t, `video/quicktime; codecs="hvc1"`, ContentType(fs.MimeTypeMOV, CodecHEVC))
})
t.Run("MP4", func(t *testing.T) {
assert.Equal(t, fs.MimeTypeMP4, ContentType(fs.MimeTypeMP4, ""))
@ -22,6 +22,6 @@ func TestContentType(t *testing.T) {
assert.Equal(t, ContentTypeAVC, ContentType(fs.MimeTypeMP4, CodecAVC))
})
t.Run("MP4_HVC", func(t *testing.T) {
assert.Equal(t, `video/mp4; codecs="hvc1"`, ContentType(fs.MimeTypeMP4, CodecHVC))
assert.Equal(t, `video/mp4; codecs="hvc1"`, ContentType(fs.MimeTypeMP4, CodecHEVC))
})
}

View file

@ -147,7 +147,7 @@ func Probe(file io.ReadSeeker) (info Info, err error) {
// Detect codec by searching for matching chunks.
if info.VideoCodec == "" {
if found, _ := ChunkHVC1.DataOffset(file); found > 0 {
info.VideoCodec = CodecHVC
info.VideoCodec = CodecHEVC
}
}

View file

@ -91,7 +91,7 @@ func TestProbeFile(t *testing.T) {
assert.Equal(t, int64(0), info.VideoOffset)
assert.Equal(t, int64(-1), info.ThumbOffset)
assert.Equal(t, media.Video, info.MediaType)
assert.Equal(t, CodecHVC, info.VideoCodec)
assert.Equal(t, CodecHEVC, info.VideoCodec)
assert.Equal(t, fs.MimeTypeMOV, info.VideoMimeType)
assert.Equal(t, ContentTypeMOV+`; codecs="hvc1"`, info.VideoContentType())
assert.Equal(t, "1.166666666s", info.Duration.String())

View file

@ -2,30 +2,34 @@ package video
// Types maps identifiers to standards.
var Types = Standards{
"": AVC,
"mp4": MP4,
"mpeg4": MP4,
"avc": AVC,
"avc1": AVC,
"hvc": HEVC,
"hvc1": HEVC,
"hevc": HEVC,
"hevC": HEVC,
"evc": EVC,
"evc1": EVC,
"evcC": EVC,
"vvc": VVC,
"vvc1": VVC,
"vvcC": VVC,
"vp8": VP8,
"vp80": VP8,
"vp9": VP9,
"vp90": VP9,
"av1": AV1,
"av01": AV1,
"ogg": OGV,
"ogv": OGV,
"webm": WebM,
"": AVC,
"mp4": MP4,
"mpeg4": MP4,
"avc": AVC,
"avc1": AVC,
"hevc": HEVC,
"hevC": HEVC,
"hvc": HEVC,
"hvc1": HEVC,
"v_hvc": HEVC,
"v_hvc1": HEVC,
"hev": HEVC,
"hev1": HEVC,
"evc": EVC,
"evc1": EVC,
"evcC": EVC,
"vvc": VVC,
"vvc1": VVC,
"vvcC": VVC,
"vp8": VP8,
"vp80": VP8,
"vp9": VP9,
"vp90": VP9,
"av1": AV1,
"av01": AV1,
"ogg": OGV,
"ogv": OGV,
"webm": WebM,
}
// Standards maps names to standardized formats.

View file

@ -39,7 +39,7 @@ var AVC = Type{
// HEVC aka High Efficiency Video Coding (H.265).
var HEVC = Type{
Codec: CodecHVC,
Codec: CodecHEVC,
FileType: fs.VideoHEVC,
WidthLimit: 0,
HeightLimit: 0,