From d2e8c7afadbdb88cb1f097a62949d73de6e39ba3 Mon Sep 17 00:00:00 2001 From: Michael Mayer Date: Tue, 26 May 2026 15:36:42 +0000 Subject: [PATCH] Config: Add option to exclude video formats from FFmpeg #5617 --- internal/commands/download.go | 6 + internal/commands/download_impl.go | 6 + internal/commands/video_remux.go | 9 ++ internal/config/config.go | 4 + internal/config/config_ffmpeg.go | 8 ++ internal/config/config_ffmpeg_test.go | 18 +++ internal/config/flags.go | 7 + internal/config/options.go | 1 + internal/config/report.go | 1 + internal/config/test.go | 2 + internal/ffmpeg/exclude.go | 11 ++ internal/ffmpeg/exclude_test.go | 47 +++++++ internal/photoprism/convert.go | 15 +++ internal/photoprism/convert_image_jpeg.go | 2 +- internal/photoprism/convert_image_png.go | 2 +- internal/photoprism/convert_test.go | 68 ++++++++++ internal/photoprism/convert_video_avc.go | 10 ++ pkg/clean/format.go | 15 +++ pkg/clean/format_test.go | 59 ++++++++ pkg/media/video/codecs.go | 56 ++++---- pkg/media/video/codecs_test.go | 10 ++ pkg/media/video/formats.go | 85 ++++++++++++ pkg/media/video/formats_test.go | 155 ++++++++++++++++++++++ 23 files changed, 572 insertions(+), 25 deletions(-) create mode 100644 internal/ffmpeg/exclude.go create mode 100644 internal/ffmpeg/exclude_test.go create mode 100644 pkg/clean/format.go create mode 100644 pkg/clean/format_test.go create mode 100644 pkg/media/video/formats.go create mode 100644 pkg/media/video/formats_test.go diff --git a/internal/commands/download.go b/internal/commands/download.go index 96d62839d..8d462b43a 100644 --- a/internal/commands/download.go +++ b/internal/commands/download.go @@ -261,6 +261,12 @@ func downloadAction(ctx *cli.Context) error { continue } + if ffmpeg.Exclude.Contains(result.Info.VCodec, result.Info.Ext, result.Info.Container) { + log.Warnf("skipping %s because format %s is on the FFmpeg exclude list", clean.Log(u.String()), clean.Log(result.Info.VCodec)) + failures++ + continue + } + // Best-effort creation time for file method when not remuxing locally. if ytRemux { if created := dl.CreatedFromInfo(result.Info); !created.IsZero() { diff --git a/internal/commands/download_impl.go b/internal/commands/download_impl.go index 853656829..7869000b4 100644 --- a/internal/commands/download_impl.go +++ b/internal/commands/download_impl.go @@ -165,6 +165,12 @@ func runDownload(conf *config.Config, opts DownloadOpts, inputURLs []string) err continue } + if ffmpeg.Exclude.Contains(result.Info.VCodec, result.Info.Ext, result.Info.Container) { + log.Warnf("skipping %s because format %s is on the FFmpeg exclude list", clean.Log(u.String()), clean.Log(result.Info.VCodec)) + failures++ + continue + } + // Best-effort creation time for file method when not remuxing locally. if ytRemux { if created := dl.CreatedFromInfo(result.Info); !created.IsZero() { diff --git a/internal/commands/video_remux.go b/internal/commands/video_remux.go index b835f37eb..8472a2758 100644 --- a/internal/commands/video_remux.go +++ b/internal/commands/video_remux.go @@ -154,6 +154,11 @@ func videoBuildRemuxPlans(conf *config.Config, results []search.Photo, force boo } } + if ffmpeg.Exclude.Contains(videoFile.FileCodec, fs.FileType(srcPath).String()) { + log.Warnf("remux: skipping %s because format %s is on the FFmpeg exclude list", clean.Log(videoFile.FileName), clean.Log(videoFile.FileCodec)) + continue + } + destPath := fs.StripKnownExt(srcPath) + fs.ExtMp4 useSidecar := false indexPath := destPath @@ -202,6 +207,10 @@ func videoRemuxFile(conf *config.Config, convert *photoprism.Convert, plan video opt := encode.NewRemuxOptions(conf.FFmpegBin(), fs.VideoMp4, true) opt.Force = true + if ffmpeg.Exclude.Contains(fs.FileType(plan.SrcPath).String()) { + return fmt.Errorf("format %s is excluded from FFmpeg processing", clean.Log(fs.FileType(plan.SrcPath).String())) + } + if err = ffmpeg.RemuxFile(plan.SrcPath, tempPath, opt); err != nil { return err } diff --git a/internal/config/config.go b/internal/config/config.go index 8297c6bc5..938b4fe45 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -56,6 +56,7 @@ import ( "github.com/photoprism/photoprism/internal/config/customize" "github.com/photoprism/photoprism/internal/config/ttl" "github.com/photoprism/photoprism/internal/entity" + "github.com/photoprism/photoprism/internal/ffmpeg" "github.com/photoprism/photoprism/internal/mutex" "github.com/photoprism/photoprism/internal/photoprism/dl" "github.com/photoprism/photoprism/internal/service/hub" @@ -379,6 +380,9 @@ func (c *Config) Propagate() { thumb.IccProfilesPath = c.IccProfilesPath() initThumbs() + // Configure FFmpeg package. + ffmpeg.Exclude = c.FFmpegExclude() + // Configure video download package. dl.YtDlpBin = c.YtDlpBin() dl.FFmpegBin = c.FFmpegBin() diff --git a/internal/config/config_ffmpeg.go b/internal/config/config_ffmpeg.go index 70d342a6b..37ce60bc4 100644 --- a/internal/config/config_ffmpeg.go +++ b/internal/config/config_ffmpeg.go @@ -7,6 +7,7 @@ import ( "github.com/photoprism/photoprism/internal/ffmpeg/encode" "github.com/photoprism/photoprism/internal/thumb" "github.com/photoprism/photoprism/pkg/fs" + "github.com/photoprism/photoprism/pkg/media/video" "github.com/photoprism/photoprism/pkg/txt" ) @@ -135,6 +136,13 @@ func (c *Config) FFmpegMapAudio() string { return c.options.FFmpegMapAudio } +// FFmpegExclude returns a comma-separated list of container and codec names +// that must not be processed by FFmpeg, e.g. for transcoding, remuxing, or +// still-frame extraction. Matching is case-insensitive. +func (c *Config) FFmpegExclude() video.Formats { + return video.NewFormats(c.options.FFmpegExclude) +} + // FFmpegOptions returns the FFmpeg options to use for video transcoding. func (c *Config) FFmpegOptions(encoder encode.Encoder, bitrate string) (encode.Options, error) { // Get options to transcode other formats with FFmpeg. diff --git a/internal/config/config_ffmpeg_test.go b/internal/config/config_ffmpeg_test.go index 31017e8a9..b3d6baa59 100644 --- a/internal/config/config_ffmpeg_test.go +++ b/internal/config/config_ffmpeg_test.go @@ -144,6 +144,24 @@ func TestConfig_FFmpegMapAudio(t *testing.T) { assert.Equal(t, encode.DefaultMapAudio, c.FFmpegMapAudio()) } +func TestConfig_FFmpegExclude(t *testing.T) { + c := NewConfig(CliTestContext()) + assert.Equal(t, "magicyuv", c.FFmpegExclude().String()) + + // String() returns formats sorted alphabetically. + c.options.FFmpegExclude = "magicyuv, hap" + assert.Equal(t, "hap, magicyuv", c.FFmpegExclude().String()) + + excluded := c.FFmpegExclude() + assert.True(t, excluded.Contains("magicyuv")) + assert.True(t, excluded.Contains("hap")) + assert.False(t, excluded.Contains("avc1")) + + c.options.FFmpegExclude = "" + assert.Equal(t, "", c.FFmpegExclude().String()) + assert.False(t, c.FFmpegExclude().Contains("magicyuv")) +} + func TestConfig_FFmpegOptions(t *testing.T) { c := NewConfig(CliTestContext()) bitrate := "25M" diff --git a/internal/config/flags.go b/internal/config/flags.go index e4ca6ff1d..2f7e88ead 100644 --- a/internal/config/flags.go +++ b/internal/config/flags.go @@ -9,6 +9,7 @@ import ( "github.com/photoprism/photoprism/internal/auth/acl" "github.com/photoprism/photoprism/internal/config/ttl" "github.com/photoprism/photoprism/internal/entity" + "github.com/photoprism/photoprism/internal/ffmpeg" "github.com/photoprism/photoprism/internal/ffmpeg/encode" "github.com/photoprism/photoprism/internal/service/cluster" "github.com/photoprism/photoprism/internal/service/hub/places" @@ -1070,6 +1071,12 @@ var Flags = CliFlags{ Value: encode.DefaultMapAudio, EnvVars: EnvVars("FFMPEG_MAP_AUDIO"), }, DocDefault: fmt.Sprintf("`%s`", encode.DefaultMapAudio)}, { + Flag: &cli.StringFlag{ + Name: "ffmpeg-exclude", + Usage: "container and codec `FORMATS` not to be processed by FFmpeg, separated by commas", + Value: ffmpeg.DefaultExclude, + EnvVars: EnvVars("FFMPEG_EXCLUDE", "FFMPEG_BLACKLIST"), + }}, { Flag: &cli.StringFlag{ Name: "exiftool-bin", Usage: "ExifTool `COMMAND` for extracting metadata", diff --git a/internal/config/options.go b/internal/config/options.go index 517c8d642..fb41e6546 100644 --- a/internal/config/options.go +++ b/internal/config/options.go @@ -215,6 +215,7 @@ type Options struct { FFmpegDevice string `yaml:"FFmpegDevice" json:"-" flag:"ffmpeg-device"` FFmpegMapVideo string `yaml:"FFmpegMapVideo" json:"FFmpegMapVideo" flag:"ffmpeg-map-video"` FFmpegMapAudio string `yaml:"FFmpegMapAudio" json:"FFmpegMapAudio" flag:"ffmpeg-map-audio"` + FFmpegExclude string `yaml:"FFmpegExclude" json:"-" flag:"ffmpeg-exclude"` ExifToolBin string `yaml:"ExifToolBin" json:"-" flag:"exiftool-bin"` SipsBin string `yaml:"SipsBin" json:"-" flag:"sips-bin"` SipsExclude string `yaml:"SipsExclude" json:"-" flag:"sips-exclude"` diff --git a/internal/config/report.go b/internal/config/report.go index cd188565f..36210772e 100644 --- a/internal/config/report.go +++ b/internal/config/report.go @@ -286,6 +286,7 @@ func (c *Config) Report() (rows [][]string, cols []string) { {"ffmpeg-device", c.FFmpegDevice()}, {"ffmpeg-map-video", c.FFmpegMapVideo()}, {"ffmpeg-map-audio", c.FFmpegMapAudio()}, + {"ffmpeg-exclude", c.FFmpegExclude().String()}, {"exiftool-bin", c.ExifToolBin()}, {"sips-bin", c.SipsBin()}, {"sips-exclude", c.SipsExclude()}, diff --git a/internal/config/test.go b/internal/config/test.go index 58ce2b795..deadd8a13 100644 --- a/internal/config/test.go +++ b/internal/config/test.go @@ -419,6 +419,7 @@ func CliTestContext() *cli.Context { globalSet.String("darktable-cli", config.DarktableBin, "doc") globalSet.String("darktable-exclude", config.DarktableExclude, "doc") globalSet.String("sips-exclude", config.SipsExclude, "doc") + globalSet.String("ffmpeg-exclude", config.FFmpegExclude, "doc") globalSet.String("wakeup-interval", "1h34m9s", "doc") globalSet.Bool("vision-api", config.VisionApi, "doc") globalSet.Bool("detect-nsfw", config.DetectNSFW, "doc") @@ -455,6 +456,7 @@ func CliTestContext() *cli.Context { LogErr(c.Set("darktable-cli", config.DarktableBin)) LogErr(c.Set("darktable-exclude", "raf, cr3")) LogErr(c.Set("sips-exclude", "avif, avifs, thm")) + LogErr(c.Set("ffmpeg-exclude", "magicyuv")) LogErr(c.Set("wakeup-interval", "1h34m9s")) LogErr(c.Set("vision-api", "true")) LogErr(c.Set("detect-nsfw", "true")) diff --git a/internal/ffmpeg/exclude.go b/internal/ffmpeg/exclude.go new file mode 100644 index 000000000..c5e32c61a --- /dev/null +++ b/internal/ffmpeg/exclude.go @@ -0,0 +1,11 @@ +package ffmpeg + +import ( + "github.com/photoprism/photoprism/pkg/media/video" +) + +// Exclude contains the video container and codec formats that should not be processed by FFmpeg. +var Exclude = video.NewFormats(video.CodecMagicYUV) + +// DefaultExclude contains the video container and codec formats that should not be processed by default. +var DefaultExclude = Exclude.String() diff --git a/internal/ffmpeg/exclude_test.go b/internal/ffmpeg/exclude_test.go new file mode 100644 index 000000000..c43f4f7d8 --- /dev/null +++ b/internal/ffmpeg/exclude_test.go @@ -0,0 +1,47 @@ +package ffmpeg + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/photoprism/photoprism/pkg/media/video" +) + +func TestExcludeDefault(t *testing.T) { + t.Run("ContainsMagicYUVByDefault", func(t *testing.T) { + assert.NotEmpty(t, DefaultExclude) + def := video.NewFormats(DefaultExclude) + assert.True(t, def.Contains(video.CodecMagicYUV)) + }) + + t.Run("ConsistentWithExclude", func(t *testing.T) { + // DefaultExclude is captured at package init from Exclude.String(), + // so the canonical default must round-trip through NewFormats. + assert.Equal(t, DefaultExclude, video.NewFormats(DefaultExclude).String()) + }) +} + +func TestExclude(t *testing.T) { + // Snapshot and restore the package variable so tests don't leak state. + saved := Exclude + t.Cleanup(func() { Exclude = saved }) + + t.Run("DefaultBlocksMagicYUV", func(t *testing.T) { + Exclude = video.NewFormats(DefaultExclude) + assert.True(t, Exclude.Contains(video.CodecMagicYUV)) + assert.False(t, Exclude.Contains(video.CodecAvc1)) + }) + + t.Run("EmptyAllowsEverything", func(t *testing.T) { + Exclude = video.NewFormats("") + assert.False(t, Exclude.Contains(video.CodecMagicYUV)) + }) + + t.Run("CustomList", func(t *testing.T) { + Exclude = video.NewFormats("avi, hap") + assert.True(t, Exclude.Contains("avi")) + assert.True(t, Exclude.Contains("hap")) + assert.False(t, Exclude.Contains(video.CodecMagicYUV)) + }) +} diff --git a/internal/photoprism/convert.go b/internal/photoprism/convert.go index b8aadca55..8dbeac5ca 100644 --- a/internal/photoprism/convert.go +++ b/internal/photoprism/convert.go @@ -11,6 +11,7 @@ import ( "github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/event" + "github.com/photoprism/photoprism/internal/ffmpeg" "github.com/photoprism/photoprism/internal/mutex" "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/fs" @@ -18,6 +19,7 @@ import ( "github.com/photoprism/photoprism/pkg/i18n" "github.com/photoprism/photoprism/pkg/list" "github.com/photoprism/photoprism/pkg/log/status" + "github.com/photoprism/photoprism/pkg/media/video" ) // Convert represents a file format conversion worker. @@ -28,6 +30,7 @@ type Convert struct { darktableExclude fs.ExtList rawTherapeeExclude fs.ExtList imageMagickExclude fs.ExtList + ffmpegExclude video.Formats } // NewConvert returns a new file format conversion worker. @@ -38,11 +41,23 @@ func NewConvert(conf *config.Config) *Convert { darktableExclude: fs.NewExtList(conf.DarktableExclude()), rawTherapeeExclude: fs.NewExtList(conf.RawTherapeeExclude()), imageMagickExclude: fs.NewExtList(conf.ImageMagickExclude()), + ffmpegExclude: ffmpeg.Exclude, } return c } +// FFmpegAllowed reports whether the media file's codec and container are both +// absent from the FFmpegExclude list and the file may therefore be handed to +// FFmpeg. A file with unknown codec and unknown container passes through. +func (w *Convert) FFmpegAllowed(f *MediaFile) bool { + if f == nil || len(w.ffmpegExclude) == 0 { + return true + } + + return !w.ffmpegExclude.Contains(f.MetaData().Codec, f.FileType().String()) +} + // Cancel stops the current conversion operation. func (w *Convert) Cancel() { mutex.IndexWorker.Cancel() diff --git a/internal/photoprism/convert_image_jpeg.go b/internal/photoprism/convert_image_jpeg.go index c18dbb9a3..29c0b92b0 100644 --- a/internal/photoprism/convert_image_jpeg.go +++ b/internal/photoprism/convert_image_jpeg.go @@ -32,7 +32,7 @@ func (w *Convert) JpegConvertCmds(f *MediaFile, jpegName string, xmpName string) } // Use FFmpeg to extract video stills from videos, e.g. to use them as cover images. - if f.IsAnimated() && !f.IsWebp() && w.conf.FFmpegEnabled() { + if f.IsAnimated() && !f.IsWebp() && w.conf.FFmpegEnabled() && w.FFmpegAllowed(f) { result = append(result, NewConvertCmd( ffmpeg.ExtractJpegImageCmd(f.FileName(), jpegName, encode.NewPreviewImageOptions(w.conf.FFmpegBin(), f.Duration()))), ) diff --git a/internal/photoprism/convert_image_png.go b/internal/photoprism/convert_image_png.go index d8c5512b8..f63791db9 100644 --- a/internal/photoprism/convert_image_png.go +++ b/internal/photoprism/convert_image_png.go @@ -31,7 +31,7 @@ func (w *Convert) PngConvertCmds(f *MediaFile, pngName string) (result ConvertCm } // Use FFmpeg to extract video stills from videos, e.g. to use them as cover images. - if f.IsAnimated() && !f.IsWebp() && w.conf.FFmpegEnabled() { + if f.IsAnimated() && !f.IsWebp() && w.conf.FFmpegEnabled() && w.FFmpegAllowed(f) { // Use "ffmpeg" to extract a PNG still image from the video. result = append(result, NewConvertCmd( ffmpeg.ExtractPngImageCmd(f.FileName(), pngName, encode.NewPreviewImageOptions(w.conf.FFmpegBin(), f.Duration()))), diff --git a/internal/photoprism/convert_test.go b/internal/photoprism/convert_test.go index b22718a73..c75810faa 100644 --- a/internal/photoprism/convert_test.go +++ b/internal/photoprism/convert_test.go @@ -8,7 +8,10 @@ import ( "github.com/stretchr/testify/assert" "github.com/photoprism/photoprism/internal/config" + "github.com/photoprism/photoprism/internal/ffmpeg" + "github.com/photoprism/photoprism/internal/meta" "github.com/photoprism/photoprism/pkg/fs" + "github.com/photoprism/photoprism/pkg/media/video" ) func TestNewConvert(t *testing.T) { @@ -69,3 +72,68 @@ func TestConvert_Start(t *testing.T) { assert.NotEqual(t, oldHash, newHash, "Fingerprint of old and new JPEG file must not be the same") } + +// fakeMediaFile returns a MediaFile with no backing file but a pinned codec +// and filename, so MediaFile.Ok() reports false and MetaData()/FileType() +// return the pinned values without invoking ExifTool or stat'ing the disk. +func fakeMediaFile(codec, fileName string) *MediaFile { + return &MediaFile{ + fileName: fileName, + metaData: meta.Data{Codec: codec}, + } +} + +func TestConvert_FFmpegAllowed(t *testing.T) { + cnf := config.TestConfig() + convert := NewConvert(cnf) + + // NewConvert captures the current ffmpeg.Exclude. Pin the field directly so + // the test stays deterministic regardless of what the global is set to. + convert.ffmpegExclude = video.NewFormats("magicyuv, avi") + + t.Run("NilFile", func(t *testing.T) { + assert.True(t, convert.FFmpegAllowed(nil)) + }) + + t.Run("EmptyCodecAndType", func(t *testing.T) { + assert.True(t, convert.FFmpegAllowed(fakeMediaFile("", ""))) + }) + + t.Run("AllowedCodec", func(t *testing.T) { + assert.True(t, convert.FFmpegAllowed(fakeMediaFile("avc1", "/tmp/clip.mp4"))) + }) + + t.Run("ExcludedCodec", func(t *testing.T) { + assert.False(t, convert.FFmpegAllowed(fakeMediaFile("magicyuv", "/tmp/clip.mp4"))) + }) + + t.Run("ExcludedCodecMixedCase", func(t *testing.T) { + assert.False(t, convert.FFmpegAllowed(fakeMediaFile("MagicYUV", "/tmp/clip.mp4"))) + }) + + t.Run("ExcludedContainerOnly", func(t *testing.T) { + // Codec is unknown, but the container extension is on the list. + assert.False(t, convert.FFmpegAllowed(fakeMediaFile("", "/tmp/clip.avi"))) + }) + + t.Run("AllowedContainer", func(t *testing.T) { + assert.True(t, convert.FFmpegAllowed(fakeMediaFile("", "/tmp/clip.mp4"))) + }) + + t.Run("EmptyExcludeList", func(t *testing.T) { + convert.ffmpegExclude = video.NewFormats("") + assert.True(t, convert.FFmpegAllowed(fakeMediaFile("magicyuv", "/tmp/clip.avi"))) + }) +} + +func TestNewConvert_CapturesFFmpegExclude(t *testing.T) { + // Snapshot and restore the package variable so other tests aren't affected. + saved := ffmpeg.Exclude + t.Cleanup(func() { ffmpeg.Exclude = saved }) + + ffmpeg.Exclude = video.NewFormats("magicyuv") + + convert := NewConvert(config.TestConfig()) + assert.False(t, convert.FFmpegAllowed(fakeMediaFile("magicyuv", "/tmp/clip.mp4"))) + assert.True(t, convert.FFmpegAllowed(fakeMediaFile("avc1", "/tmp/clip.mp4"))) +} diff --git a/internal/photoprism/convert_video_avc.go b/internal/photoprism/convert_video_avc.go index bb6d0dfe2..a6a9d1f8a 100644 --- a/internal/photoprism/convert_video_avc.go +++ b/internal/photoprism/convert_video_avc.go @@ -35,6 +35,16 @@ func (w *Convert) ToAvc(f *MediaFile, encoder encode.Encoder, noMutex, force boo return nil, fmt.Errorf("convert: %s is empty", logFileName) } + // Skip files whose codec or container is on the FFmpeg exclude list. + if !w.FFmpegAllowed(f) { + format := clean.Log(f.MetaData().Codec) + if format == "" { + format = clean.Log(f.FileType().String()) + } + log.Warnf("convert: skipping %s because format %s is on the FFmpeg exclude list", logFileName, format) + return nil, fmt.Errorf("convert: format %s is excluded from FFmpeg processing", format) + } + // AVC video filename. var avcName string diff --git a/pkg/clean/format.go b/pkg/clean/format.go new file mode 100644 index 000000000..ecf6576d6 --- /dev/null +++ b/pkg/clean/format.go @@ -0,0 +1,15 @@ +package clean + +import ( + "strings" +) + +// FormatSep defines the string separating multiple format identifiers in a list. +const FormatSep = "," + +// Format lowercases and strips the surrounding whitespace and punctuation +// from format identifiers so that inputs like `"mp4"`, ` .avi `, or `magicyuv,` +// normalize to canonical short names suitable for map lookups. +func Format(s string) string { + return strings.ToLower(strings.Trim(s, " .,;:\"'`")) +} diff --git a/pkg/clean/format_test.go b/pkg/clean/format_test.go new file mode 100644 index 000000000..1ef9de5ee --- /dev/null +++ b/pkg/clean/format_test.go @@ -0,0 +1,59 @@ +package clean + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFormat(t *testing.T) { + t.Run("Empty", func(t *testing.T) { + assert.Equal(t, "", Format("")) + }) + + t.Run("AlreadyClean", func(t *testing.T) { + assert.Equal(t, "mp4", Format("mp4")) + }) + + t.Run("MixedCase", func(t *testing.T) { + assert.Equal(t, "magicyuv", Format("MagicYUV")) + assert.Equal(t, "m8rg", Format("M8RG")) + }) + + t.Run("LeadingDot", func(t *testing.T) { + assert.Equal(t, "avi", Format(".avi")) + }) + + t.Run("TrailingPunctuation", func(t *testing.T) { + assert.Equal(t, "avi", Format("avi,")) + assert.Equal(t, "avi", Format("avi;")) + assert.Equal(t, "avi", Format("avi.")) + }) + + t.Run("Whitespace", func(t *testing.T) { + assert.Equal(t, "magicyuv", Format(" magicyuv ")) + // Only space is trimmed; tabs and newlines stay as-is. + assert.Equal(t, "\tmagicyuv", Format("\tmagicyuv ")) + }) + + t.Run("Quoted", func(t *testing.T) { + assert.Equal(t, "mp4", Format(`"mp4"`)) + assert.Equal(t, "mp4", Format("'mp4'")) + assert.Equal(t, "mp4", Format("`mp4`")) + }) + + t.Run("PunctuationOnly", func(t *testing.T) { + assert.Equal(t, "", Format(".,;:")) + assert.Equal(t, "", Format(" ")) + }) + + t.Run("PreservesInnerPunctuation", func(t *testing.T) { + // Only leading/trailing characters are trimmed. + assert.Equal(t, "h.264", Format("H.264")) + assert.Equal(t, "video/mp4", Format("Video/MP4")) + }) +} + +func TestFormatSep(t *testing.T) { + assert.Equal(t, ",", FormatSep) +} diff --git a/pkg/media/video/codecs.go b/pkg/media/video/codecs.go index e15791a90..60ee7a262 100644 --- a/pkg/media/video/codecs.go +++ b/pkg/media/video/codecs.go @@ -13,29 +13,30 @@ type Codec = string // - https://cconcolato.github.io/media-mime-support/ // - https://thorium.rocks/misc/h265-tester.html const ( - CodecUnknown Codec = "" - CodecAvc1 Codec = "avc1" // Advanced Video Coding (AVC), also known as H.264 - CodecDva1 Codec = "dva1" // AVC-based Dolby Vision derived from avc1 - CodecAvc2 Codec = "avc2" // Advanced Video Coding (AVC), might not be fully supported on macOS - CodecAvc3 Codec = "avc3" // Advanced Video Coding (AVC), might not be fully supported on macOS - CodecAvc4 Codec = "avc4" // Advanced Video Coding (AVC), might not be fully supported on macOS - CodecDvav Codec = "dvav" // AVC-based Dolby Vision derived from avc3 - CodecHvc1 Codec = "hvc1" // High Efficiency Video Coding (HEVC), also known as H.265 - CodecDvh1 Codec = "dvh1" // HEVC-based Dolby Vision derived from hvc1 - CodecHvc2 Codec = "hvc2" // HEVC video with constrained extractors and/or aggregators and parameter sets only in the Sample Entry - CodecHvc3 Codec = "hvc3" // HEVC video with extractors and/or aggregators and parameter sets only in the Sample Entry - CodecHev1 Codec = "hev1" // HEVC video with parameter sets also in the Samples, not supported on macOS - CodecDvhe Codec = "dvhe" // HEVC-based Dolby Vision derived from hev1 - CodecHev2 Codec = "hev2" // HEVC video with constrained extractors and/or aggregators and parameter sets in the Sample Entry or samples - CodecHev3 Codec = "hev3" // HEVC video with extractors and/or aggregators and parameter sets in the Sample Entry or samples - CodecVvc1 Codec = "vvc1" // Versatile Video Coding (VVC), also known as H.266 - CodecEvc1 Codec = "evc1" // MPEG-5 Essential Video Coding (EVC), also known as ISO/IEC 23094-1 - CodecAv01 Codec = "av01" // AOMedia Video 1 (AV1) - CodecVp08 Codec = "vp08" // Google VP8 - CodecVp09 Codec = "vp09" // Google VP9 - CodecTheora Codec = "ogv" // Ogg Vorbis Video - CodecM2TS Codec = "m2t" // MPEG-2 Transport Stream - CodecWebm Codec = "webm" // Google WebM + CodecUnknown Codec = "" + CodecAvc1 Codec = "avc1" // Advanced Video Coding (AVC), also known as H.264 + CodecDva1 Codec = "dva1" // AVC-based Dolby Vision derived from avc1 + CodecAvc2 Codec = "avc2" // Advanced Video Coding (AVC), might not be fully supported on macOS + CodecAvc3 Codec = "avc3" // Advanced Video Coding (AVC), might not be fully supported on macOS + CodecAvc4 Codec = "avc4" // Advanced Video Coding (AVC), might not be fully supported on macOS + CodecDvav Codec = "dvav" // AVC-based Dolby Vision derived from avc3 + CodecHvc1 Codec = "hvc1" // High Efficiency Video Coding (HEVC), also known as H.265 + CodecDvh1 Codec = "dvh1" // HEVC-based Dolby Vision derived from hvc1 + CodecHvc2 Codec = "hvc2" // HEVC video with constrained extractors and/or aggregators and parameter sets only in the Sample Entry + CodecHvc3 Codec = "hvc3" // HEVC video with extractors and/or aggregators and parameter sets only in the Sample Entry + CodecHev1 Codec = "hev1" // HEVC video with parameter sets also in the Samples, not supported on macOS + CodecDvhe Codec = "dvhe" // HEVC-based Dolby Vision derived from hev1 + CodecHev2 Codec = "hev2" // HEVC video with constrained extractors and/or aggregators and parameter sets in the Sample Entry or samples + CodecHev3 Codec = "hev3" // HEVC video with extractors and/or aggregators and parameter sets in the Sample Entry or samples + CodecVvc1 Codec = "vvc1" // Versatile Video Coding (VVC), also known as H.266 + CodecEvc1 Codec = "evc1" // MPEG-5 Essential Video Coding (EVC), also known as ISO/IEC 23094-1 + CodecAv01 Codec = "av01" // AOMedia Video 1 (AV1) + CodecVp08 Codec = "vp08" // Google VP8 + CodecVp09 Codec = "vp09" // Google VP9 + CodecTheora Codec = "ogv" // Ogg Vorbis Video + CodecM2TS Codec = "m2t" // MPEG-2 Transport Stream + CodecWebm Codec = "webm" // Google WebM + CodecMagicYUV Codec = "magicyuv" // MagicYUV lossless video ) // Codecs maps supported string identifiers to standard Codec types. @@ -122,6 +123,15 @@ var Codecs = StandardCodecs{ "theora": CodecTheora, "v_theora": CodecTheora, CodecWebm: CodecWebm, + CodecMagicYUV: CodecMagicYUV, + "m8rg": CodecMagicYUV, // RGB + "m8ra": CodecMagicYUV, // RGBA + "m8rb": CodecMagicYUV, // RGB (alt) + "m8y0": CodecMagicYUV, // YUV 4:2:0 + "m8y2": CodecMagicYUV, // YUV 4:2:2 + "m8y4": CodecMagicYUV, // YUV 4:4:4 + "m8ya": CodecMagicYUV, // YUVA 4:4:4:4 + "m8g0": CodecMagicYUV, // Grayscale } // StandardCodecs maps strings to codec types. diff --git a/pkg/media/video/codecs_test.go b/pkg/media/video/codecs_test.go index 40f3e802f..08013a164 100644 --- a/pkg/media/video/codecs_test.go +++ b/pkg/media/video/codecs_test.go @@ -22,4 +22,14 @@ func TestCodecs(t *testing.T) { if val := Codecs["vvcC"]; val != CodecVvc1 { t.Fatal("codec should be CodecVVC") } + + if val := Codecs["magicyuv"]; val != CodecMagicYUV { + t.Fatal("codec should be CodecMagicYUV") + } + + for _, fourcc := range []string{"m8rg", "m8ra", "m8rb", "m8y0", "m8y2", "m8y4", "m8ya", "m8g0"} { + if val := Codecs[fourcc]; val != CodecMagicYUV { + t.Fatalf("FourCC %q should map to CodecMagicYUV, got %q", fourcc, val) + } + } } diff --git a/pkg/media/video/formats.go b/pkg/media/video/formats.go new file mode 100644 index 000000000..3dd4ccef7 --- /dev/null +++ b/pkg/media/video/formats.go @@ -0,0 +1,85 @@ +package video + +import ( + "sort" + "strings" + + "github.com/photoprism/photoprism/pkg/clean" +) + +// Formats represents a set of video container and codec names for allow/exclude +// lookups. Keys are stored lowercased and matched case-insensitively. +type Formats map[string]bool + +// NewFormats creates a Formats set from a comma-separated string of container +// and/or codec names. Returns an empty set when the input is empty. +func NewFormats(formats ...string) Formats { + n := strings.Count(strings.Join(formats, clean.FormatSep), clean.FormatSep) + list := make(Formats, n+8) + + for i := range formats { + list.Set(formats[i]) + } + + return list +} + +// Contains reports whether any of the given format names is in the list. +// Empty values are skipped so callers can pass both a codec and a container +// even when only one is known. +func (b Formats) Contains(formats ...string) bool { + if len(b) == 0 || len(formats) == 0 { + return false + } + + for _, format := range formats { + if format = clean.Format(format); format == "" { + continue + } else if _, ok := b[format]; ok { + return true + } + } + + return false +} + +// Allow reports whether the given format is NOT in the list. +func (b Formats) Allow(format string) bool { + return !b.Contains(format) +} + +// Set adds a comma-separated list of format names to the list. +func (b Formats) Set(formats string) { + if formats == "" { + return + } + + for c := range strings.SplitSeq(formats, clean.FormatSep) { + b.Add(c) + } +} + +// Add adds a format name to the list after normalizing it. +func (b Formats) Add(format string) { + format = clean.Format(format) + if format == "" { + return + } + + b[format] = true +} + +// String returns the list as a comma-separated string in alphabetical order. +func (b Formats) String() string { + if len(b) == 0 { + return "" + } + + list := make([]string, 0, len(b)) + for s := range b { + list = append(list, s) + } + + sort.Strings(list) + return strings.Join(list, ", ") +} diff --git a/pkg/media/video/formats_test.go b/pkg/media/video/formats_test.go new file mode 100644 index 000000000..bb59f93ad --- /dev/null +++ b/pkg/media/video/formats_test.go @@ -0,0 +1,155 @@ +package video + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewFormats(t *testing.T) { + t.Run("Empty", func(t *testing.T) { + list := NewFormats("") + assert.Empty(t, list) + }) + + t.Run("Single", func(t *testing.T) { + list := NewFormats("magicyuv") + assert.Len(t, list, 1) + assert.True(t, list.Contains("magicyuv")) + }) + + t.Run("CommaSeparated", func(t *testing.T) { + list := NewFormats("magicyuv, m8rg ,M8RA") + assert.Len(t, list, 3) + assert.True(t, list.Contains("magicyuv")) + assert.True(t, list.Contains("m8rg")) + assert.True(t, list.Contains("m8ra")) + }) + + t.Run("MixedContainerAndCodec", func(t *testing.T) { + list := NewFormats("avi, magicyuv") + assert.Len(t, list, 2) + assert.True(t, list.Contains("avi")) + assert.True(t, list.Contains("magicyuv")) + }) +} + +func TestFormats_Contains(t *testing.T) { + list := NewFormats("magicyuv, m8rg") + + t.Run("Match", func(t *testing.T) { + assert.True(t, list.Contains("magicyuv")) + }) + + t.Run("CaseInsensitive", func(t *testing.T) { + assert.True(t, list.Contains("MagicYUV")) + assert.True(t, list.Contains("M8RG")) + }) + + t.Run("Trimmed", func(t *testing.T) { + assert.True(t, list.Contains(" magicyuv ")) + assert.True(t, list.Contains(`"magicyuv"`)) + assert.True(t, list.Contains(".magicyuv")) + }) + + t.Run("NoMatch", func(t *testing.T) { + assert.False(t, list.Contains("avc1")) + }) + + t.Run("Empty", func(t *testing.T) { + assert.False(t, list.Contains("")) + }) + + t.Run("EmptyList", func(t *testing.T) { + empty := NewFormats("") + assert.False(t, empty.Contains("magicyuv")) + }) + + t.Run("NoArgs", func(t *testing.T) { + assert.False(t, list.Contains()) + }) + + t.Run("MultipleArgsFirstMatches", func(t *testing.T) { + assert.True(t, list.Contains("magicyuv", "avi")) + }) + + t.Run("MultipleArgsSecondMatches", func(t *testing.T) { + assert.True(t, list.Contains("avc1", "magicyuv")) + }) + + t.Run("MultipleArgsWithEmpty", func(t *testing.T) { + assert.True(t, list.Contains("", "magicyuv")) + assert.False(t, list.Contains("", "avc1")) + }) + + t.Run("MultipleArgsNoMatch", func(t *testing.T) { + assert.False(t, list.Contains("avc1", "hevc", "mp4")) + }) +} + +func TestFormats_Allow(t *testing.T) { + list := NewFormats("magicyuv") + + assert.False(t, list.Allow("magicyuv")) + assert.True(t, list.Allow("avc1")) + assert.True(t, list.Allow("")) +} + +func TestFormats_Set(t *testing.T) { + t.Run("Empty", func(t *testing.T) { + list := make(Formats) + list.Set("") + assert.Empty(t, list) + }) + + t.Run("AddsToExisting", func(t *testing.T) { + list := NewFormats("magicyuv") + list.Set("m8rg, AVI") + assert.Len(t, list, 3) + assert.True(t, list.Contains("magicyuv")) + assert.True(t, list.Contains("m8rg")) + assert.True(t, list.Contains("avi")) + }) + + t.Run("Duplicate", func(t *testing.T) { + list := NewFormats("magicyuv") + list.Set("MAGICYUV, magicyuv") + assert.Len(t, list, 1) + }) +} + +func TestFormats_Add(t *testing.T) { + t.Run("Single", func(t *testing.T) { + list := make(Formats) + list.Add("MagicYUV") + assert.Len(t, list, 1) + assert.True(t, list.Contains("magicyuv")) + }) + + t.Run("Empty", func(t *testing.T) { + list := make(Formats) + list.Add("") + list.Add(" ") + list.Add(".") + assert.Empty(t, list) + }) + + t.Run("Idempotent", func(t *testing.T) { + list := make(Formats) + list.Add("avi") + list.Add("avi") + assert.Len(t, list, 1) + }) +} + +func TestFormats_String(t *testing.T) { + t.Run("Empty", func(t *testing.T) { + list := NewFormats("") + assert.Equal(t, "", list.String()) + }) + + t.Run("Sorted", func(t *testing.T) { + list := NewFormats("magicyuv, m8rg, m8ra") + assert.Equal(t, "m8ra, m8rg, magicyuv", list.String()) + }) +}