Config: Add option to exclude video formats from FFmpeg #5617

This commit is contained in:
Michael Mayer 2026-05-26 15:36:42 +00:00
parent cd17444807
commit d2e8c7afad
23 changed files with 572 additions and 25 deletions

View file

@ -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() {

View file

@ -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() {

View file

@ -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
}

View file

@ -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()

View file

@ -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.

View file

@ -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"

View file

@ -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",

View file

@ -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"`

View file

@ -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()},

View file

@ -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"))

View file

@ -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()

View file

@ -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))
})
}

View file

@ -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()

View file

@ -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()))),
)

View file

@ -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()))),

View file

@ -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")))
}

View file

@ -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

15
pkg/clean/format.go Normal file
View file

@ -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, " .,;:\"'`"))
}

59
pkg/clean/format_test.go Normal file
View file

@ -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)
}

View file

@ -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.

View file

@ -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)
}
}
}

View file

@ -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, ", ")
}

View file

@ -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())
})
}