From 09102cdba7bd69d95c409ac414a07da678112b6f Mon Sep 17 00:00:00 2001 From: pablu Date: Thu, 6 Aug 2026 16:58:59 +0200 Subject: [PATCH] fix: fix container duration offset eof --- generator.go | 103 +++++++++++++++++++++++++++++++++++++++++++++------ 1 file 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) {