From 43efc84bf68de8c26c45950b2de690564dfff24f Mon Sep 17 00:00:00 2001 From: Stu Leak Date: Sat, 20 Dec 2025 13:29:09 -0500 Subject: [PATCH] Estimate missing audio bitrate in metadata --- DONE.md | 1 + TODO.md | 1 + main.go | 80 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 81 insertions(+), 1 deletion(-) diff --git a/DONE.md b/DONE.md index d4c0787..56c83da 100644 --- a/DONE.md +++ b/DONE.md @@ -766,6 +766,7 @@ This file tracks completed features, fixes, and milestones. ### Recent Fixes - ✅ Fixed aspect ratio default from 16:9 to Source (dev7) - ✅ Ranked benchmark results by score and added cancel confirmation +- ✅ Added estimated audio bitrate fallback when metadata is missing - ✅ Stabilized video seeking and embedded rendering - ✅ Improved player window positioning - ✅ Fixed clear video functionality diff --git a/TODO.md b/TODO.md index eae7f51..d0a493a 100644 --- a/TODO.md +++ b/TODO.md @@ -41,6 +41,7 @@ This file tracks upcoming features, improvements, and known issues. - Lossless option only for H.265/AV1 - Dynamic dropdown based on codec - Lossless + Target Size mode support + - Audio bitrate estimation when metadata is missing ## Priority Features for dev20+ diff --git a/main.go b/main.go index 9c8a70d..9d79777 100644 --- a/main.go +++ b/main.go @@ -7180,7 +7180,11 @@ func buildMetadataPanel(state *appState, src *videoSource, min fyne.Size) (fyne. audioBitrate := "--" if src.AudioBitrate > 0 { - audioBitrate = fmt.Sprintf("%d kbps", src.AudioBitrate/1000) + prefix := "" + if src.AudioBitrateEstimated { + prefix = "~" + } + audioBitrate = fmt.Sprintf("%s%d kbps", prefix, src.AudioBitrate/1000) } // Format advanced metadata @@ -10386,6 +10390,7 @@ type videoSource struct { AudioCodec string Bitrate int // Video bitrate in bits per second AudioBitrate int // Audio bitrate in bits per second + AudioBitrateEstimated bool FrameRate float64 PixelFormat string AudioRate int @@ -10459,6 +10464,11 @@ func probeVideo(path string) (*videoSource, error) { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() + var fileSize int64 + if info, err := os.Stat(path); err == nil { + fileSize = info.Size() + } + cmd := exec.CommandContext(ctx, "ffprobe", "-v", "quiet", "-print_format", "json", @@ -10495,6 +10505,7 @@ func probeVideo(path string) (*videoSource, error) { Channels int `json:"channels"` AvgFrameRate string `json:"avg_frame_rate"` FieldOrder string `json:"field_order"` + Tags map[string]interface{} `json:"tags"` Disposition struct { AttachedPic int `json:"attached_pic"` } `json:"disposition"` @@ -10512,7 +10523,9 @@ func probeVideo(path string) (*videoSource, error) { DisplayName: filepath.Base(path), Format: utils.FirstNonEmpty(result.Format.Format, result.Format.FormatName), } + var formatBitrate int if rate, err := utils.ParseInt(result.Format.BitRate); err == nil { + formatBitrate = rate src.Bitrate = rate } if durStr := result.Format.Duration; durStr != "" { @@ -10537,6 +10550,7 @@ func probeVideo(path string) (*videoSource, error) { // Track if we've found the main video stream (not cover art) foundMainVideo := false var coverArtStreamIndex int = -1 + var videoStreamBitrate int for _, stream := range result.Streams { switch stream.CodecType { @@ -10567,6 +10581,9 @@ func probeVideo(path string) (*videoSource, error) { if stream.PixFmt != "" { src.PixelFormat = stream.PixFmt } + if br, err := utils.ParseInt(stream.BitRate); err == nil && br > 0 { + videoStreamBitrate = br + } } if src.Bitrate == 0 { if br, err := utils.ParseInt(stream.BitRate); err == nil { @@ -10582,10 +10599,41 @@ func probeVideo(path string) (*videoSource, error) { if stream.Channels > 0 { src.Channels = stream.Channels } + if br, err := utils.ParseInt(stream.BitRate); err == nil && br > 0 { + src.AudioBitrate = br + } else if br := parseBitrateTag(stream.Tags); br > 0 { + src.AudioBitrate = br + } } } } + if src.AudioCodec != "" && src.AudioBitrate == 0 { + totalBps := 0 + if formatBitrate > 0 { + totalBps = formatBitrate + } else if src.Duration > 0 && fileSize > 0 { + totalBps = int(float64(fileSize*8) / src.Duration) + } + + baseVideo := videoStreamBitrate + if baseVideo == 0 && formatBitrate == 0 && src.Bitrate > 0 { + baseVideo = src.Bitrate + } + + estimated := 0 + if totalBps > 0 && baseVideo > 0 && totalBps > baseVideo { + estimated = totalBps - baseVideo + } + if estimated == 0 { + estimated = defaultAudioBitrate(src.Channels) + } + if estimated > 0 { + src.AudioBitrate = estimated + src.AudioBitrateEstimated = true + } + } + // Extract embedded cover art if present if coverArtStreamIndex >= 0 { coverPath := filepath.Join(os.TempDir(), fmt.Sprintf("videotools-embedded-cover-%d.png", time.Now().UnixNano())) @@ -10608,6 +10656,36 @@ func probeVideo(path string) (*videoSource, error) { return src, nil } +func parseBitrateTag(tags map[string]interface{}) int { + if len(tags) == 0 { + return 0 + } + keys := []string{"BPS", "BPS-eng", "bit_rate", "variant_bitrate"} + for _, key := range keys { + if val, ok := tags[key]; ok { + if rate, err := utils.ParseInt(fmt.Sprint(val)); err == nil && rate > 0 { + return rate + } + } + } + return 0 +} + +func defaultAudioBitrate(channels int) int { + switch channels { + case 1: + return 96000 + case 2: + return 128000 + case 6: + return 256000 + case 8: + return 320000 + default: + return 128000 + } +} + func normalizeTags(tags map[string]interface{}) map[string]string { normalized := make(map[string]string, len(tags)) for k, v := range tags {