From 519f0c49c9ec139632a7b0031cadfcf91564f448 Mon Sep 17 00:00:00 2001 From: Michael Mayer Date: Fri, 24 Jun 2022 06:59:22 +0200 Subject: [PATCH] Videos: Stream OGV, VP8, VP9, AV1, WebM, and HEVC if supported #2461 --- assets/static/video/404.mp4 | Bin 0 -> 35353 bytes frontend/src/common/caniuse.js | 47 ++++++++++++ frontend/src/model/photo.js | 32 ++++++-- frontend/tests/unit/common/caniuse_test.js | 48 ++++++++++++ internal/api/headers.go | 1 - internal/api/video.go | 27 ++++--- internal/api/video_test.go | 7 ++ internal/config/config_server.go | 7 +- internal/config/config_test.go | 7 ++ internal/meta/codec.go | 2 + internal/meta/data.go | 2 +- internal/meta/json_exiftool.go | 10 ++- internal/meta/json_test.go | 85 ++++++++++++++++++++- internal/meta/testdata/earth.ogv.json | 28 +++++++ internal/meta/testdata/stream.webm.json | 30 ++++++++ internal/meta/testdata/webm-vp8.json | 32 ++++++++ internal/meta/testdata/webm-vp9.json | 33 ++++++++ internal/meta/testdata/yoga-av1.webm.json | 37 +++++++++ pkg/clean/codec.go | 23 ++++++ pkg/clean/codec_test.go | 28 +++++++ pkg/clean/id_test.go | 3 + pkg/video/codecs.go | 41 +++++++--- pkg/video/standards.go | 7 ++ pkg/video/types.go | 54 ++++++++++--- 24 files changed, 547 insertions(+), 44 deletions(-) create mode 100644 assets/static/video/404.mp4 create mode 100644 frontend/src/common/caniuse.js create mode 100644 frontend/tests/unit/common/caniuse_test.js create mode 100644 internal/meta/testdata/earth.ogv.json create mode 100644 internal/meta/testdata/stream.webm.json create mode 100644 internal/meta/testdata/webm-vp8.json create mode 100644 internal/meta/testdata/webm-vp9.json create mode 100644 internal/meta/testdata/yoga-av1.webm.json create mode 100644 pkg/clean/codec.go create mode 100644 pkg/clean/codec_test.go diff --git a/assets/static/video/404.mp4 b/assets/static/video/404.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..103d6e7089c65d4b89e7a9ee38565cbc4f86632f GIT binary patch literal 35353 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 + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + The AGPL is supplemented by our Trademark and Brand Guidelines, + which describe how our Brand Assets may be used: + + +Feel free to send an email to hello@photoprism.app if you have questions, +want to support our work, or just want to say hello. + +Additional information can be found in our Developer Guide: + + +*/ + +export const canUseVideo = !!document.createElement("video").canPlayType; +export const canUseAvc = canUseVideo + ? !!document.createElement("video").canPlayType('video/mp4; codecs="avc1"') + : false; +export const canUseOGV = canUseVideo // Ogg Theora + ? !!document.createElement("video").canPlayType("video/ogg") + : false; +export const canUseVP8 = canUseVideo // WebM VP8 + ? !!document.createElement("video").canPlayType('video/webm; codecs="vp8"') + : false; +export const canUseVP9 = canUseVideo // WebM VP9 + ? !!document.createElement("video").canPlayType('video/webm; codecs="vp9"') + : false; +export const canUseAv1 = canUseVideo // AV1, Main Profile, Level 4.0 Main Tier, 8-bit + ? !!document.createElement("video").canPlayType('video/webm; codecs="av01.0.08M.08"') + : false; +export const canUseWebm = canUseVideo + ? !!document.createElement("video").canPlayType("video/webm") + : false; +export const canUseHevc = canUseVideo + ? !!document.createElement("video").canPlayType('video/mp4; codecs="hvc1"') + : false; diff --git a/frontend/src/model/photo.js b/frontend/src/model/photo.js index cd379a765..478acf741 100644 --- a/frontend/src/model/photo.js +++ b/frontend/src/model/photo.js @@ -37,12 +37,19 @@ import { $gettext } from "common/vm"; import Clipboard from "common/clipboard"; import download from "common/download"; import * as src from "common/src"; +import { canUseOGV, canUseVP8, canUseVP9, canUseAv1, canUseWebm, canUseHevc } from "common/caniuse"; +export const CodecOGV = "ogv"; +export const CodecVP8 = "vp8"; +export const CodecVP9 = "vp9"; +export const CodecAv1 = "av01"; export const CodecAvc1 = "avc1"; export const CodecHvc1 = "hvc1"; export const FormatMp4 = "mp4"; +export const FormatAv1 = "av01"; export const FormatAvc = "avc"; -export const FormatHvc = "hvc"; +export const FormatHevc = "hevc"; +export const FormatWebM = "webm"; export const FormatGif = "gif"; export const FormatJpeg = "jpg"; export const MediaImage = "image"; @@ -476,14 +483,25 @@ export class Photo extends RestModel { videoUrl() { let file = this.videoFile(); - const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); - - if (file && file.Codec === CodecHvc1 && isSafari) { - return `${config.apiUri}/videos/${file.Hash}/${config.previewToken()}/${FormatHvc}`; - } if (file) { - return `${config.apiUri}/videos/${file.Hash}/${config.previewToken()}/${FormatAvc}`; + let videoFormat = FormatAvc; + + if (canUseHevc && file.Codec === CodecHvc1) { + videoFormat = FormatHevc; + } else if (canUseOGV && file.Codec === CodecOGV) { + videoFormat = CodecOGV; + } else if (canUseVP8 && file.Codec === CodecVP8) { + videoFormat = CodecVP8; + } else if (canUseVP9 && file.Codec === CodecVP9) { + videoFormat = CodecVP9; + } else if (canUseAv1 && file.Codec === CodecAv1) { + videoFormat = FormatAv1; + } else if (canUseWebm && file.FileType === FormatWebM) { + videoFormat = FormatWebM; + } + + return `${config.apiUri}/videos/${file.Hash}/${config.previewToken()}/${videoFormat}`; } return `${config.apiUri}/videos/${this.Hash}/${config.previewToken()}/${FormatAvc}`; diff --git a/frontend/tests/unit/common/caniuse_test.js b/frontend/tests/unit/common/caniuse_test.js new file mode 100644 index 000000000..a03754e4f --- /dev/null +++ b/frontend/tests/unit/common/caniuse_test.js @@ -0,0 +1,48 @@ +import "../fixtures"; +import { + canUseAv1, + canUseAvc, + canUseHevc, + canUseOGV, + canUseVideo, + canUseVP8, + canUseVP9, + canUseWebm, +} from "common/caniuse"; + +let chai = require("chai/chai"); +let assert = chai.assert; + +describe("common/caniuse", () => { + it("canUseVideo", () => { + assert.equal(canUseVideo, true); + }); + + it("canUseAvc", () => { + assert.equal(canUseAvc, true); + }); + + it("canUseOGV", () => { + assert.equal(canUseOGV, true); + }); + + it("canUseVP8", () => { + assert.equal(canUseVP8, true); + }); + + it("canUseVP9", () => { + assert.equal(canUseVP9, true); + }); + + it("canUseAv1", () => { + assert.equal(canUseAv1, true); + }); + + it("canUseWebm", () => { + assert.equal(canUseWebm, true); + }); + + it("canUseHevc", () => { + assert.equal(canUseHevc, false); + }); +}); diff --git a/internal/api/headers.go b/internal/api/headers.go index 728833a75..4abdef6db 100644 --- a/internal/api/headers.go +++ b/internal/api/headers.go @@ -11,7 +11,6 @@ import ( const ( ContentTypeAvc = `video/mp4; codecs="avc1"` - ContentTypeHvc = `video/mp4; codecs="hvc1"` ) // AddCacheHeader adds a cache control header to the response. diff --git a/internal/api/video.go b/internal/api/video.go index 7ae98f1b4..189f575c2 100644 --- a/internal/api/video.go +++ b/internal/api/video.go @@ -1,6 +1,7 @@ package api import ( + "fmt" "net/http" "github.com/photoprism/photoprism/pkg/video" @@ -65,28 +66,32 @@ func GetVideo(router *gin.RouterGroup) { fileName := photoprism.FileName(f.FileRoot, f.FileName) if mf, err := photoprism.NewMediaFile(fileName); err != nil { - log.Errorf("video: file %s is missing", clean.Log(f.FileName)) - c.Data(http.StatusOK, "image/svg+xml", videoIconSvg) - // Set missing flag so that the file doesn't show up in search results anymore. logError("video", f.Update("FileMissing", true)) - return - } else if f.FileCodec != string(format.Codec) { + // Log error and default to 404.mp4 + log.Errorf("video: file %s is missing", clean.Log(f.FileName)) + fileName = service.Config().StaticFile("video/404.mp4") + AddContentTypeHeader(c, ContentTypeAvc) + } else if f.FileCodec != "" && f.FileCodec == string(format.Codec) || format.Codec == video.UnknownCodec && f.FileType == string(format.File) { + if f.FileCodec != "" && f.FileCodec != f.FileType { + log.Debugf("video: %s has matching codec %s", clean.Log(f.FileName), clean.Log(f.FileCodec)) + AddContentTypeHeader(c, fmt.Sprintf("%s; codecs=\"%s\"", f.FileMime, clean.Codec(f.FileCodec))) + } else { + log.Debugf("video: %s has matching type %s", clean.Log(f.FileName), clean.Log(f.FileType)) + AddContentTypeHeader(c, f.FileMime) + } + } else { conv := service.Convert() if avcFile, err := conv.ToAvc(mf, service.Config().FFmpegEncoder(), false, false); err != nil { + // Log error and default to 404.mp4 log.Errorf("video: transcoding %s failed", clean.Log(f.FileName)) - c.Data(http.StatusOK, "image/svg+xml", videoIconSvg) - return + fileName = service.Config().StaticFile("video/404.mp4") } else { fileName = avcFile.FileName() } - } - if video.Types[formatName] == video.HEVC { - AddContentTypeHeader(c, ContentTypeHvc) - } else { AddContentTypeHeader(c, ContentTypeAvc) } diff --git a/internal/api/video_test.go b/internal/api/video_test.go index 24df498c4..c1a8070e8 100644 --- a/internal/api/video_test.go +++ b/internal/api/video_test.go @@ -1,13 +1,20 @@ package api import ( + "fmt" "net/http" "testing" + "github.com/photoprism/photoprism/pkg/clean" + "github.com/stretchr/testify/assert" ) func TestGetVideo(t *testing.T) { + t.Run("ContentTypeAvc", func(t *testing.T) { + assert.Equal(t, ContentTypeAvc, fmt.Sprintf("%s; codecs=\"%s\"", "video/mp4", clean.Codec("avc1"))) + }) + t.Run("invalid hash", func(t *testing.T) { app, router, conf := NewApiTest() GetVideo(router) diff --git a/internal/config/config_server.go b/internal/config/config_server.go index 245bae237..5db3b5e36 100644 --- a/internal/config/config_server.go +++ b/internal/config/config_server.go @@ -69,11 +69,16 @@ func (c *Config) TemplateName() string { return "index.tmpl" } -// StaticPath returns the static assets path. +// StaticPath returns the static assets' path. func (c *Config) StaticPath() string { return filepath.Join(c.AssetsPath(), "static") } +// StaticFile returns the path to a static file. +func (c *Config) StaticFile(fileName string) string { + return filepath.Join(c.AssetsPath(), "static", fileName) +} + // BuildPath returns the static build path. func (c *Config) BuildPath() string { return filepath.Join(c.StaticPath(), "build") diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 2e0f72717..67db87141 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -231,6 +231,13 @@ func TestConfig_StaticPath(t *testing.T) { assert.Equal(t, "/go/src/github.com/photoprism/photoprism/assets/static", path) } +func TestConfig_StaticFile(t *testing.T) { + c := NewConfig(CliTestContext()) + + path := c.StaticFile("video/404.mp4") + assert.Equal(t, "/go/src/github.com/photoprism/photoprism/assets/static/video/404.mp4", path) +} + func TestConfig_BuildPath(t *testing.T) { c := NewConfig(CliTestContext()) diff --git a/internal/meta/codec.go b/internal/meta/codec.go index bc0b39ab8..7c94a3164 100644 --- a/internal/meta/codec.go +++ b/internal/meta/codec.go @@ -5,6 +5,8 @@ import ( ) const CodecUnknown = "" +const CodecAv1 = string(video.CodecAV1) +const CodecVP9 = string(video.CodecVP9) const CodecAvc1 = string(video.CodecAVC) const CodecJpeg = "jpeg" const CodecHeic = "heic" diff --git a/internal/meta/data.go b/internal/meta/data.go index 469d656d7..c0c7654b1 100644 --- a/internal/meta/data.go +++ b/internal/meta/data.go @@ -25,7 +25,7 @@ type Data struct { Duration time.Duration `meta:"Duration,MediaDuration,TrackDuration"` FPS float64 `meta:"VideoFrameRate,VideoAvgFrameRate"` Frames int `meta:"FrameCount"` - Codec string `meta:"CompressorID,FileType"` + Codec string `meta:"CompressorID,VideoCodecID,CodecID,FileType"` Title string `meta:"Headline,Title" xmp:"dc:title" dc:"title,title.Alt"` Subject string `meta:"Subject,PersonInImage,ObjectName,HierarchicalSubject,CatalogSets" xmp:"Subject"` Keywords Keywords `meta:"Keywords"` diff --git a/internal/meta/json_exiftool.go b/internal/meta/json_exiftool.go index 0db4f8460..180299fd0 100644 --- a/internal/meta/json_exiftool.go +++ b/internal/meta/json_exiftool.go @@ -9,6 +9,8 @@ import ( "strings" "time" + "github.com/photoprism/photoprism/pkg/video" + "github.com/photoprism/photoprism/pkg/projection" "github.com/photoprism/photoprism/pkg/clean" @@ -292,10 +294,14 @@ func (data *Data) Exiftool(jsonData []byte, originalName string) (err error) { } } - // Normalize compression information. + // Normalize codec name. data.Codec = strings.ToLower(data.Codec) - if strings.Contains(data.Codec, CodecJpeg) { + if strings.Contains(data.Codec, CodecJpeg) { // JPEG Image? data.Codec = CodecJpeg + } else if c, ok := video.Codecs[data.Codec]; ok { // Video codec? + data.Codec = string(c) + } else if strings.HasPrefix(data.Codec, "a_") { // Audio codec? + data.Codec = "" } // Validate and normalize optional DocumentID. diff --git a/internal/meta/json_test.go b/internal/meta/json_test.go index 0e8fc35f3..2dd0577ff 100644 --- a/internal/meta/json_test.go +++ b/internal/meta/json_test.go @@ -4,11 +4,10 @@ import ( "testing" "time" - "github.com/photoprism/photoprism/pkg/video" + "github.com/stretchr/testify/assert" "github.com/photoprism/photoprism/pkg/projection" - - "github.com/stretchr/testify/assert" + "github.com/photoprism/photoprism/pkg/video" ) func TestJSON(t *testing.T) { @@ -40,6 +39,86 @@ func TestJSON(t *testing.T) { assert.Equal(t, "", data.LensModel) }) + t.Run("yoga-av1.webm.json", func(t *testing.T) { + data, err := JSON("testdata/yoga-av1.webm.json", "") + + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, "yoga-av1.webm", data.FileName) + assert.Equal(t, "", data.Codec) + assert.Equal(t, "20s", data.Duration.String()) + assert.Equal(t, 854, data.Width) + assert.Equal(t, 480, data.Height) + assert.Equal(t, 854, data.ActualWidth()) + assert.Equal(t, 480, data.ActualHeight()) + }) + + t.Run("stream.webm.json", func(t *testing.T) { + data, err := JSON("testdata/stream.webm.json", "") + + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, "stream.webm", data.FileName) + assert.Equal(t, CodecAv1, data.Codec) + assert.Equal(t, "2m24s", data.Duration.String()) + assert.Equal(t, 1280, data.Width) + assert.Equal(t, 720, data.Height) + assert.Equal(t, 1280, data.ActualWidth()) + assert.Equal(t, 720, data.ActualHeight()) + }) + + t.Run("earth.ogv.json", func(t *testing.T) { + data, err := JSON("testdata/earth.ogv.json", "") + + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, "earth.ogv", data.FileName) + assert.Equal(t, string(video.CodecOGV), data.Codec) + assert.Equal(t, "0s", data.Duration.String()) + assert.Equal(t, 1280, data.Width) + assert.Equal(t, 720, data.Height) + assert.Equal(t, 1280, data.ActualWidth()) + assert.Equal(t, 720, data.ActualHeight()) + }) + + t.Run("webm-vp8.json", func(t *testing.T) { + data, err := JSON("testdata/webm-vp8.json", "") + + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, "earth.vp8.webm", data.FileName) + assert.Equal(t, string(video.CodecVP8), data.Codec) + assert.Equal(t, "30s", data.Duration.String()) + assert.Equal(t, 1920, data.Width) + assert.Equal(t, 1080, data.Height) + assert.Equal(t, 1920, data.ActualWidth()) + assert.Equal(t, 1080, data.ActualHeight()) + }) + + t.Run("webm-vp9.json", func(t *testing.T) { + data, err := JSON("testdata/webm-vp9.json", "") + + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, "earth-animation.ogv.720p.vp9.webm", data.FileName) + assert.Equal(t, string(video.CodecVP9), data.Codec) + assert.Equal(t, "8s", data.Duration.String()) + assert.Equal(t, 1280, data.Width) + assert.Equal(t, 720, data.Height) + assert.Equal(t, 1280, data.ActualWidth()) + assert.Equal(t, 720, data.ActualHeight()) + }) + t.Run("gopher-telegram.json", func(t *testing.T) { data, err := JSON("testdata/gopher-telegram.json", "") diff --git a/internal/meta/testdata/earth.ogv.json b/internal/meta/testdata/earth.ogv.json new file mode 100644 index 000000000..73c47e6f2 --- /dev/null +++ b/internal/meta/testdata/earth.ogv.json @@ -0,0 +1,28 @@ +[{ + "SourceFile": "earth.ogv", + "ExifToolVersion": 12.42, + "FileName": "earth.ogv", + "Directory": ".", + "FileSize": 6397571, + "FileModifyDate": "2022:06:24 03:16:55+00:00", + "FileAccessDate": "2022:06:24 03:18:39+00:00", + "FileInodeChangeDate": "2022:06:24 03:18:39+00:00", + "FilePermissions": 100664, + "FileType": "OGV", + "FileTypeExtension": "OGV", + "MIMEType": "video/ogg", + "TheoraVersion": "3 2 0", + "ImageWidth": 1280, + "ImageHeight": 720, + "XOffset": 0, + "YOffset": 0, + "FrameRate": 30, + "ColorSpace": 1, + "NominalVideoBitrate": 0, + "Quality": 63, + "PixelFormat": 0, + "Vendor": "Xiph.Org libTheora I 20060526 3 2 0", + "Encoder": "ffmpeg2theora 0.19", + "ImageSize": "1280 720", + "Megapixels": 0.9216 +}] \ No newline at end of file diff --git a/internal/meta/testdata/stream.webm.json b/internal/meta/testdata/stream.webm.json new file mode 100644 index 000000000..2847e656a --- /dev/null +++ b/internal/meta/testdata/stream.webm.json @@ -0,0 +1,30 @@ +[{ + "SourceFile": "stream.webm", + "ExifToolVersion": 12.42, + "FileName": "stream.webm", + "Directory": ".", + "FileSize": 57052418, + "FileModifyDate": "2022:06:24 01:43:22+00:00", + "FileAccessDate": "2022:06:24 01:43:39+00:00", + "FileInodeChangeDate": "2022:06:24 01:44:40+00:00", + "FilePermissions": 100664, + "FileType": "WEBM", + "FileTypeExtension": "WEBM", + "MIMEType": "video/webm", + "EBMLVersion": 1, + "EBMLReadVersion": 1, + "DocType": "webm", + "DocTypeVersion": 4, + "DocTypeReadVersion": 2, + "TimecodeScale": 0.001, + "Duration": 144.12, + "MuxingApp": "libwebm-0.2.1.0", + "WritingApp": "aomenc 1.0.0", + "TrackNumber": 1, + "TrackType": 1, + "VideoCodecID": "V_AV1", + "ImageWidth": 1280, + "ImageHeight": 720, + "ImageSize": "1280 720", + "Megapixels": 0.9216 +}] \ No newline at end of file diff --git a/internal/meta/testdata/webm-vp8.json b/internal/meta/testdata/webm-vp8.json new file mode 100644 index 000000000..e0b0a795e --- /dev/null +++ b/internal/meta/testdata/webm-vp8.json @@ -0,0 +1,32 @@ +[{ + "SourceFile": "earth.vp8.webm", + "ExifToolVersion": 12.42, + "FileName": "earth.vp8.webm", + "Directory": ".", + "FileSize": 2962571, + "FileModifyDate": "2022:06:24 03:17:47+00:00", + "FileAccessDate": "2022:06:24 03:18:39+00:00", + "FileInodeChangeDate": "2022:06:24 03:18:39+00:00", + "FilePermissions": 100664, + "FileType": "WEBM", + "FileTypeExtension": "WEBM", + "MIMEType": "video/webm", + "EBMLVersion": 1, + "EBMLReadVersion": 1, + "DocType": "webm", + "DocTypeVersion": 2, + "DocTypeReadVersion": 2, + "TimecodeScale": 0.001, + "MuxingApp": "Lavf56.40.101", + "WritingApp": "Lavf56.40.101", + "Duration": 30, + "TrackNumber": 1, + "TrackLanguage": "eng", + "CodecID": "V_VP8", + "TrackType": 1, + "VideoFrameRate": 30.0000003, + "ImageWidth": 1920, + "ImageHeight": 1080, + "ImageSize": "1920 1080", + "Megapixels": 2.0736 +}] \ No newline at end of file diff --git a/internal/meta/testdata/webm-vp9.json b/internal/meta/testdata/webm-vp9.json new file mode 100644 index 000000000..925b03b80 --- /dev/null +++ b/internal/meta/testdata/webm-vp9.json @@ -0,0 +1,33 @@ +[{ + "SourceFile": "earth-animation.ogv.720p.vp9.webm", + "ExifToolVersion": 12.42, + "FileName": "earth-animation.ogv.720p.vp9.webm", + "Directory": ".", + "FileSize": 1806960, + "FileModifyDate": "2022:06:24 02:37:55+00:00", + "FileAccessDate": "2022:06:24 02:40:20+00:00", + "FileInodeChangeDate": "2022:06:24 02:40:20+00:00", + "FilePermissions": 100664, + "FileType": "WEBM", + "FileTypeExtension": "WEBM", + "MIMEType": "video/webm", + "EBMLVersion": 1, + "EBMLReadVersion": 1, + "DocType": "webm", + "DocTypeVersion": 2, + "DocTypeReadVersion": 2, + "TimecodeScale": 0.001, + "MuxingApp": "Lavf57.56.101", + "WritingApp": "Lavf57.56.101", + "Duration": 8.033, + "TrackNumber": 1, + "TrackLanguage": "und", + "CodecID": "V_VP9", + "TrackType": 1, + "VideoFrameRate": 30.0000003, + "ImageWidth": 1280, + "ImageHeight": 720, + "VideoScanType": 2, + "ImageSize": "1280 720", + "Megapixels": 0.9216 +}] diff --git a/internal/meta/testdata/yoga-av1.webm.json b/internal/meta/testdata/yoga-av1.webm.json new file mode 100644 index 000000000..62bdc8023 --- /dev/null +++ b/internal/meta/testdata/yoga-av1.webm.json @@ -0,0 +1,37 @@ +[{ + "SourceFile": "yoga-av1.webm", + "ExifToolVersion": 12.42, + "FileName": "yoga-av1.webm", + "Directory": ".", + "FileSize": 1826192, + "FileModifyDate": "2022:06:24 01:25:23+00:00", + "FileAccessDate": "2022:06:24 01:26:04+00:00", + "FileInodeChangeDate": "2022:06:24 01:26:04+00:00", + "FilePermissions": 100664, + "FileType": "WEBM", + "FileTypeExtension": "WEBM", + "MIMEType": "video/webm", + "EBMLVersion": 1, + "EBMLReadVersion": 1, + "DocType": "webm", + "DocTypeVersion": 4, + "DocTypeReadVersion": 2, + "TimecodeScale": 0.001, + "MuxingApp": "Lavf58.37.100", + "WritingApp": "Lavf58.37.100", + "Duration": 20.302, + "VideoFrameRate": 25, + "ImageWidth": 854, + "ImageHeight": 480, + "TrackNumber": 2, + "TrackLanguage": "eng", + "CodecID": "A_OPUS", + "TrackType": 2, + "AudioChannels": 2, + "AudioSampleRate": 48000, + "AudioBitsPerSample": 32, + "TagName": "DURATION", + "TagString": "00:00:20.302000000", + "ImageSize": "854 480", + "Megapixels": 0.40992 +}] diff --git a/pkg/clean/codec.go b/pkg/clean/codec.go new file mode 100644 index 000000000..47d833afd --- /dev/null +++ b/pkg/clean/codec.go @@ -0,0 +1,23 @@ +package clean + +import ( + "strings" +) + +// Codec removes non-alphanumeric characters from a string and returns it. +func Codec(s string) string { + if s == "" { + return "" + } + + // Remove unwanted characters. + s = strings.Map(func(r rune) rune { + if (r < '0' || r > '9') && (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') && r != '_' { + return -1 + } + + return r + }, s) + + return s +} diff --git a/pkg/clean/codec_test.go b/pkg/clean/codec_test.go new file mode 100644 index 000000000..0764e1ad1 --- /dev/null +++ b/pkg/clean/codec_test.go @@ -0,0 +1,28 @@ +package clean + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCodec(t *testing.T) { + t.Run("UUID", func(t *testing.T) { + assert.Equal(t, "123e4567e89b12d3A456426614174000", Codec("123e4567-e89b-12d3-A456-426614174000 ")) + }) + t.Run("left_224", func(t *testing.T) { + assert.Equal(t, "left_224", Codec("left_224")) + }) + t.Run("VP09", func(t *testing.T) { + assert.Equal(t, "VP09", Codec("VP09")) + }) + t.Run("v_vp9", func(t *testing.T) { + assert.Equal(t, "v_vp9", Codec("v_vp9")) + }) + t.Run("SHA1", func(t *testing.T) { + assert.Equal(t, "5c50ae14f339364eb8224f23c2d3abc7e79016f3READMEmd", Codec("5c50ae14f339364eb8224f23c2d3abc7e79016f3 README.md")) + }) + t.Run("Quotes", func(t *testing.T) { + assert.Equal(t, "fooBaaar23", Codec("\"foo\" Baa'ar 2```3")) + }) +} diff --git a/pkg/clean/id_test.go b/pkg/clean/id_test.go index 00d9d1e56..05c59043c 100644 --- a/pkg/clean/id_test.go +++ b/pkg/clean/id_test.go @@ -16,6 +16,9 @@ func TestIdString(t *testing.T) { t.Run("SHA1", func(t *testing.T) { assert.Equal(t, "5c50ae14f339364eb8224f23c2d3abc7e79016f3readmemd", IdString("5c50ae14f339364eb8224f23c2d3abc7e79016f3 README.md")) }) + t.Run("Quotes", func(t *testing.T) { + assert.Equal(t, "foobaaar23", IdString("\"foo\" baa'ar 2```3")) + }) } func TestIdUint(t *testing.T) { diff --git a/pkg/video/codecs.go b/pkg/video/codecs.go index 8aa818636..015bfc50a 100644 --- a/pkg/video/codecs.go +++ b/pkg/video/codecs.go @@ -2,25 +2,48 @@ package video type Codec string +// Check browser support: https://cconcolato.github.io/media-mime-support/ + const ( UnknownCodec Codec = "" CodecAVC Codec = "avc1" CodecHEVC Codec = "hvc1" CodecVVC Codec = "vvc" CodecAV1 Codec = "av01" + CodecVP8 Codec = "vp8" + CodecVP9 Codec = "vp9" + CodecOGV Codec = "ogv" + CodecWebM Codec = "webm" ) // Codecs maps identifiers to codecs. var Codecs = StandardCodecs{ - "": UnknownCodec, - "avc": CodecAVC, - "avc1": CodecAVC, - "hvc1": CodecHEVC, - "hvc": CodecHEVC, - "hevc": CodecHEVC, - "vvc": CodecVVC, - "av1": CodecAV1, - "av01": CodecAV1, + "": UnknownCodec, + "a_opus": UnknownCodec, + "a_vorbis": UnknownCodec, + "avc": CodecAVC, + "avc1": CodecAVC, + "v_avc": CodecAVC, + "v_avc1": CodecAVC, + "hevc": CodecHEVC, + "hvc": CodecHEVC, + "hvc1": CodecHEVC, + "v_hvc": CodecHEVC, + "v_hvc1": CodecHEVC, + "vvc": CodecVVC, + "v_vvc": CodecVVC, + "av1": CodecAV1, + "av01": CodecAV1, + "v_av1": CodecAV1, + "v_av01": CodecAV1, + "vp8": CodecVP8, + "vp80": CodecVP8, + "v_vp8": CodecVP8, + "vp9": CodecVP9, + "vp90": CodecVP9, + "v_vp9": CodecVP9, + "ogv": CodecOGV, + "webm": CodecWebM, } // StandardCodecs maps names to known codecs. diff --git a/pkg/video/standards.go b/pkg/video/standards.go index 00b718322..501ffbb7e 100644 --- a/pkg/video/standards.go +++ b/pkg/video/standards.go @@ -12,8 +12,15 @@ var Types = Standards{ "hevc": HEVC, "vvc": VVC, "vvc1": 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. diff --git a/pkg/video/types.go b/pkg/video/types.go index 5edbc762d..386796342 100644 --- a/pkg/video/types.go +++ b/pkg/video/types.go @@ -22,15 +22,6 @@ var AVC = Type{ Public: true, } -// AV1 aka AOMedia Video 1. -var AV1 = Type{ - File: fs.VideoAV1, - Codec: CodecAV1, - Width: 0, - Height: 0, - Public: false, -} - // HEVC aka High Efficiency Video Coding (H.265). var HEVC = Type{ File: fs.VideoHEVC, @@ -48,3 +39,48 @@ var VVC = Type{ Height: 0, Public: false, } + +// VP8 + Google WebM. +var VP8 = Type{ + File: fs.VideoWebM, + Codec: CodecVP8, + Width: 0, + Height: 0, + Public: false, +} + +// VP9 + Google WebM. +var VP9 = Type{ + File: fs.VideoWebM, + Codec: CodecVP9, + Width: 0, + Height: 0, + Public: false, +} + +// AV1 + Google WebM. +var AV1 = Type{ + File: fs.VideoWebM, + Codec: CodecAV1, + Width: 0, + Height: 0, + Public: false, +} + +// OGV aka Ogg/Theora. +var OGV = Type{ + File: fs.VideoOGV, + Codec: CodecOGV, + Width: 0, + Height: 0, + Public: false, +} + +// WebM Container. +var WebM = Type{ + File: fs.VideoWebM, + Codec: UnknownCodec, + Width: 0, + Height: 0, + Public: false, +}