5 Commits

Author SHA1 Message Date
09102cdba7 fix: fix container duration offset eof 2026-08-06 16:59:23 +02:00
cb75a2becf fix: use container duration instead of per-video-stream 2026-08-06 11:38:20 +02:00
e117522f3b chore: update module path and release 2026-08-06 11:07:15 +02:00
af6c7b3699 feat: add https safe file handling 2026-08-06 11:01:39 +02:00
Pablu23
13de03cd1c Added scale 2024-06-18 19:09:14 +02:00
5 changed files with 174 additions and 41 deletions

2
.gitignore vendored
View File

@@ -1 +1,3 @@
*.png *.png
*.webp
main

View File

@@ -6,7 +6,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
thumbnailgen "github.com/pablu23/thumbnail-gen" thumbnailgen "git.pablu.de/pablu/thumbnail-gen"
) )
var ( var (
@@ -14,6 +14,9 @@ var (
intervalFlag = flag.Int("interval", 20, "Interval in seconds between thumbnails") intervalFlag = flag.Int("interval", 20, "Interval in seconds between thumbnails")
maxThumbnailsFlag = flag.Int("max", 0, "Max Thumbnails, default as much as possible with interval") maxThumbnailsFlag = flag.Int("max", 0, "Max Thumbnails, default as much as possible with interval")
blackFilterFlag = flag.Bool("filter", true, "Try to filter out black frames, might be an expensive operation") blackFilterFlag = flag.Bool("filter", true, "Try to filter out black frames, might be an expensive operation")
formatFlag = flag.String("format", "png", "Output format")
segmentsFlag = flag.Bool("segments", false, "If active uses interval for how many segments are supposed to be done")
widthFlag = flag.Int("width", -1, "Set the width of the picture to scale to, -1 = max")
) )
func main() { func main() {
@@ -21,7 +24,15 @@ func main() {
if *pathFlag != "" { if *pathFlag != "" {
// thumbnails, num, err := thumbnailgen.GetThumbnail(*pathFlag, *intervalFlag, *maxThumbnailsFlag, *blackFilterFlag) // thumbnails, num, err := thumbnailgen.GetThumbnail(*pathFlag, *intervalFlag, *maxThumbnailsFlag, *blackFilterFlag)
thumbnails, num, err := thumbnailgen.GetThumbnailSegments(*pathFlag, 12, *blackFilterFlag) opts := thumbnailgen.NewDefaultOptions()
thumbnails, num, err := opts.Apply(func(o *thumbnailgen.Options) {
o.EnableFilter = *blackFilterFlag
o.Interval = *intervalFlag
o.Format = *formatFlag
o.UseSegments = *segmentsFlag
o.Segments = *intervalFlag
o.Scale = fmt.Sprintf("%d:-1", *widthFlag)
}).GetThumbnail(*pathFlag)
if err != nil { if err != nil {
panic(err) panic(err)
} }
@@ -30,7 +41,7 @@ func main() {
name := filepath.Base(*pathFlag) name := filepath.Base(*pathFlag)
for i, thumbnail := range thumbnails[:num] { for i, thumbnail := range thumbnails[:num] {
err := os.WriteFile(fmt.Sprintf("%s-%d.png", name, i), thumbnail, 0600) err := os.WriteFile(fmt.Sprintf("%s-%d.%s", name, i, *formatFlag), thumbnail, 0600)
if err != nil { if err != nil {
panic(err) panic(err)
} }

View File

@@ -2,6 +2,8 @@ package thumbnailgen
import ( import (
"bytes" "bytes"
"encoding/json"
"errors"
"fmt" "fmt"
"os/exec" "os/exec"
"regexp" "regexp"
@@ -16,9 +18,92 @@ type TimeFilter struct {
End float64 End float64
} }
func GetThumbnailSegments(path string, segments int, enableFilter bool) ([][]byte, int, error) { type Options struct {
Interval int
Segments int
UseSegments bool
Format string
Scale string
EnableFilter bool
MaxThumbnails int
}
func NewDefaultOptions() Options {
return Options{
Interval: 5,
Segments: 0,
UseSegments: false,
Format: "webp",
Scale: "-1:-1",
EnableFilter: true,
MaxThumbnails: 0,
}
}
func (options *Options) Apply(opts ...func(*Options)) *Options {
for _, opt := range opts {
opt(options)
}
return options
}
type videoStreamProbe struct {
Streams []struct {
CodecType string `json:"codec_type"`
Duration string `json:"duration"`
} `json:"streams"`
}
func getThumbnailDuration(path string) (float64, error) {
stderr := bytes.NewBuffer(nil)
stdout := bytes.NewBuffer(nil)
cmd := exec.Command(
"ffprobe",
"-v", "error",
"-select_streams", "v:0",
"-show_entries", "stream=codec_type,duration",
"-of", "json",
path,
)
cmd.Stdout = stdout
cmd.Stderr = stderr
if err := cmd.Run(); err != nil {
return 0, fmt.Errorf(
"probe video stream: %w: %s",
err,
strings.TrimSpace(stderr.String()),
)
}
var probe videoStreamProbe
if err := json.Unmarshal(stdout.Bytes(), &probe); err != nil {
return 0, fmt.Errorf("decode video-stream probe: %w", err)
}
if len(probe.Streams) == 0 || probe.Streams[0].CodecType != "video" {
return 0, errors.New("input has no video stream")
}
durationText := strings.TrimSpace(probe.Streams[0].Duration)
if durationText == "" || durationText == "N/A" {
return GetVideoLength(path) // container-duration fallback for animated WebP
}
duration, err := strconv.ParseFloat(durationText, 64)
if err != nil {
return 0, fmt.Errorf("parse video-stream duration %q: %w", durationText, err)
}
if duration <= 0 {
return 0, fmt.Errorf("invalid video-stream duration %v", duration)
}
return duration, nil
}
func (opts *Options) GetThumbnail(path string) ([][]byte, int, error) {
var filters []TimeFilter var filters []TimeFilter
if enableFilter { if opts.EnableFilter {
f, err := GetFilter(path) f, err := GetFilter(path)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
@@ -26,35 +111,22 @@ func GetThumbnailSegments(path string, segments int, enableFilter bool) ([][]byt
filters = f filters = f
} }
length, err := GetVideoLength(path) length, err := getThumbnailDuration(path)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
interval := length / float64(segments) var interval int
if opts.UseSegments {
interval = int(length / float64(opts.Segments))
} else {
interval = opts.Interval
}
return getThumbnailUnderlying(path, 0, filters, length, int(interval), enableFilter) return GetThumbnailUnderlying(path, opts.MaxThumbnails, filters, length, interval, opts.EnableFilter, opts.Format, opts.Scale)
} }
func GetThumbnail(path string, intervalSeconds int, maxThumbnails int, enableFilter bool) ([][]byte, int, error) { func GetThumbnailUnderlying(path string, maxThumbnails int, filters []TimeFilter, length float64, intervalSeconds int, enableFilter bool, format string, scale string) ([][]byte, int, error) {
var filters []TimeFilter
if enableFilter {
f, err := GetFilter(path)
if err != nil {
return nil, 0, err
}
filters = f
}
length, err := GetVideoLength(path)
if err != nil {
return nil, 0, err
}
return getThumbnailUnderlying(path, maxThumbnails, filters, length, intervalSeconds, enableFilter)
}
func getThumbnailUnderlying(path string, maxThumbnails int, filters []TimeFilter, length float64, intervalSeconds int, enableFilter bool) ([][]byte, int, error) {
buf := bytes.NewBuffer(nil) buf := bytes.NewBuffer(nil)
framesExtracted := 0 framesExtracted := 0
@@ -78,7 +150,7 @@ func getThumbnailUnderlying(path string, maxThumbnails int, filters []TimeFilter
} }
} }
err := GetImage(buf, path, int(time), "png") err := GetImage(buf, path, int(time), format, scale)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
@@ -110,33 +182,73 @@ func FrameLiesWithinFilter(time float64, filters []TimeFilter) (bool, float64) {
return false, time return false, time
} }
func GetImage(buf *bytes.Buffer, path string, timestamp int, format string) error { func GetImage(buf *bytes.Buffer, path string, timestamp int, format string, scale string) error {
var t time.Time var t time.Time
t = t.Add(time.Duration(timestamp) * time.Second) t = t.Add(time.Duration(timestamp) * time.Second)
cmd := exec.Command("ffmpeg", "-ss", t.Format("15:04:05"), "-i", path, "-vframes", "1", "-c:v", format, "-f", "image2pipe", "-") stderr := bytes.NewBuffer(nil)
cmd := exec.Command(
"ffmpeg",
"-ss",
t.Format("15:04:05"),
"-i",
path,
"-vframes",
"1",
"-c:v",
format,
"-filter:v",
fmt.Sprintf("scale=%s", scale),
"-f",
"image2pipe",
"-",
)
cmd.Stdout = buf cmd.Stdout = buf
err := cmd.Run() cmd.Stderr = stderr
return err if err := cmd.Run(); err != nil {
return fmt.Errorf("generate thumbnail at %s: %w: %s", t.Format("15:04:05"), err, strings.TrimSpace(stderr.String()))
}
return nil
} }
func GetVideoLength(path string) (float64, error) { func GetVideoLength(path string) (float64, error) {
cmd := exec.Command("ffprobe", "-v", "quiet", "-select_streams", "v:0", "-show_entries", "stream=duration", "-of", "default=noprint_wrappers=1:nokey=1", path) cmd := exec.Command("ffprobe", "-v", "quiet", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", path)
stderr := bytes.NewBuffer(nil)
buf := bytes.NewBuffer(nil) buf := bytes.NewBuffer(nil)
cmd.Stdout = buf cmd.Stdout = buf
err := cmd.Run() cmd.Stderr = stderr
if err != nil { if err := cmd.Run(); err != nil {
return 0, err return 0, fmt.Errorf(
"probe video duration: %w: %s",
err,
strings.TrimSpace(stderr.String()),
)
} }
return strconv.ParseFloat(strings.ReplaceAll(buf.String(), "\n", ""), 64) return strconv.ParseFloat(strings.ReplaceAll(buf.String(), "\n", ""), 64)
} }
func escapeFilterValue(value string) string {
return strings.NewReplacer(
"\\", "\\\\",
"'", "\\'",
":", "\\:",
",", "\\,",
";", "\\;",
"[", "\\[",
"]", "\\]",
).Replace(value)
}
func GetFilter(path string) ([]TimeFilter, error) { func GetFilter(path string) ([]TimeFilter, error) {
buf := bytes.NewBuffer(nil) buf := bytes.NewBuffer(nil)
cmd := exec.Command("ffprobe", "-f", "lavfi", "-i", fmt.Sprintf("movie=%s,blackdetect[out0]", path), "-show_entries", "tags=lavfi.black_start,lavfi.black_end", "-of", "default=nw=1", "-v", "quiet") stderr := bytes.NewBuffer(nil)
filter := fmt.Sprintf("movie=filename='%s',blackdetect[out0]", escapeFilterValue(path))
cmd := exec.Command("ffprobe", "-f", "lavfi", "-i", filter, "-show_entries", "tags=lavfi.black_start,lavfi.black_end", "-of", "default=nw=1", "-v", "quiet")
cmd.Stderr = stderr
cmd.Stdout = buf cmd.Stdout = buf
err := cmd.Run() if err := cmd.Run(); err != nil {
// TODO: Replace with strings.Builder return nil, fmt.Errorf("detect black frames: %w: %s", err, strings.TrimSpace(stderr.String()))
}
filterStr := buf.String() filterStr := buf.String()
filters := strings.Split(filterStr, "\n") filters := strings.Split(filterStr, "\n")
filters = slices.Compact(filters) filters = slices.Compact(filters)
@@ -176,7 +288,7 @@ func GetFilter(path string) ([]TimeFilter, error) {
i += 2 i += 2
} }
return blackFilters, err return blackFilters, nil
} }
func GetFramerate(path string) (float64, error) { func GetFramerate(path string) (float64, error) {

2
go.mod
View File

@@ -1,3 +1,3 @@
module github.com/pablu23/thumbnail-gen module git.pablu.de/pablu/thumbnail-gen
go 1.22.4 go 1.22.4

8
mise.toml Normal file
View File

@@ -0,0 +1,8 @@
[tools]
go = "1.26.0"
"go:golang.org/x/tools/gopls" = "latest"
buf = "1.72.0"
[tasks.generate]
description = "Generate Go protobuf and gRPC bindings"
run = "buf generate"