mirror of
https://github.com/photoprism/photoprism.git
synced 2026-07-17 16:49:04 +00:00
Config: Add option to exclude video formats from FFmpeg #5617
This commit is contained in:
parent
cd17444807
commit
d2e8c7afad
23 changed files with 572 additions and 25 deletions
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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()},
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
11
internal/ffmpeg/exclude.go
Normal file
11
internal/ffmpeg/exclude.go
Normal 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()
|
||||
47
internal/ffmpeg/exclude_test.go
Normal file
47
internal/ffmpeg/exclude_test.go
Normal 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))
|
||||
})
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()))),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()))),
|
||||
|
|
|
|||
|
|
@ -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")))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
15
pkg/clean/format.go
Normal 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
59
pkg/clean/format_test.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
85
pkg/media/video/formats.go
Normal file
85
pkg/media/video/formats.go
Normal 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, ", ")
|
||||
}
|
||||
155
pkg/media/video/formats_test.go
Normal file
155
pkg/media/video/formats_test.go
Normal 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())
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue