summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorpablu <me@pablu.de>2026-08-06 16:58:59 +0200
committerpablu <me@pablu.de>2026-08-06 16:59:23 +0200
commit09102cdba7bd69d95c409ac414a07da678112b6f (patch)
tree37458323dcfb3d7e61b84ec8d58a1f9aa03d94ce
parentcb75a2becf6dd62e8fd8da1031c140a46482fd78 (diff)
fix: fix container duration offset eof
-rw-r--r--generator.go103
1 files changed, 92 insertions, 11 deletions
diff --git a/generator.go b/generator.go
index 65cb22b..157bc6f 100644
--- a/generator.go
+++ b/generator.go
@@ -2,6 +2,8 @@ package thumbnailgen
import (
"bytes"
+ "encoding/json"
+ "errors"
"fmt"
"os/exec"
"regexp"
@@ -46,6 +48,59 @@ func (options *Options) Apply(opts ...func(*Options)) *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
if opts.EnableFilter {
@@ -56,7 +111,7 @@ func (opts *Options) GetThumbnail(path string) ([][]byte, int, error) {
filters = f
}
- length, err := GetVideoLength(path)
+ length, err := getThumbnailDuration(path)
if err != nil {
return nil, 0, err
}
@@ -130,21 +185,44 @@ func FrameLiesWithinFilter(time float64, filters []TimeFilter) (bool, float64) {
func GetImage(buf *bytes.Buffer, path string, timestamp int, format string, scale string) error {
var t time.Time
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, "-filter:v", fmt.Sprintf("scale=%s", scale), "-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
- err := cmd.Run()
- return err
+ cmd.Stderr = stderr
+ 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) {
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)
cmd.Stdout = buf
- err := cmd.Run()
- if err != nil {
- return 0, err
+ cmd.Stderr = stderr
+ if err := cmd.Run(); err != nil {
+ return 0, fmt.Errorf(
+ "probe video duration: %w: %s",
+ err,
+ strings.TrimSpace(stderr.String()),
+ )
}
-
return strconv.ParseFloat(strings.ReplaceAll(buf.String(), "\n", ""), 64)
}
@@ -162,12 +240,15 @@ func escapeFilterValue(value string) string {
func GetFilter(path string) ([]TimeFilter, error) {
buf := bytes.NewBuffer(nil)
+ 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
- err := cmd.Run()
- // TODO: Replace with strings.Builder
+ if err := cmd.Run(); err != nil {
+ return nil, fmt.Errorf("detect black frames: %w: %s", err, strings.TrimSpace(stderr.String()))
+ }
filterStr := buf.String()
filters := strings.Split(filterStr, "\n")
filters = slices.Compact(filters)
@@ -207,7 +288,7 @@ func GetFilter(path string) ([]TimeFilter, error) {
i += 2
}
- return blackFilters, err
+ return blackFilters, nil
}
func GetFramerate(path string) (float64, error) {