Compare commits

...

2 Commits

Author SHA1 Message Date
121a61d627 Add unit selector for target file size 2025-12-20 13:35:39 -05:00
43efc84bf6 Estimate missing audio bitrate in metadata 2025-12-20 13:29:09 -05:00
4 changed files with 175 additions and 14 deletions

View File

@ -766,6 +766,8 @@ 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
- ✅ Made target file size input unit-selectable with numeric-only entry
- ✅ Stabilized video seeking and embedded rendering
- ✅ Improved player window positioning
- ✅ Fixed clear video functionality

View File

@ -41,6 +41,8 @@ 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
- Target size unit selector and numeric entry
## Priority Features for dev20+

View File

@ -217,15 +217,18 @@ func ParseFileSize(sizeStr string) (int64, error) {
if err != nil {
return 0, fmt.Errorf("invalid size format: %s", sizeStr)
}
if unit == "" {
unit = "MB"
}
// Convert to bytes
multiplier := int64(1)
switch unit {
case "KB":
case "K", "KB":
multiplier = 1024
case "MB":
case "M", "MB":
multiplier = 1024 * 1024
case "GB":
case "G", "GB":
multiplier = 1024 * 1024 * 1024
case "B", "":
multiplier = 1

176
main.go
View File

@ -5966,7 +5966,61 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
// Target File Size with smart presets + manual entry
targetFileSizeEntry = widget.NewEntry()
targetFileSizeEntry.SetPlaceHolder("e.g., 25MB, 100MB, 8MB")
targetFileSizeEntry.SetPlaceHolder("e.g., 250")
targetFileSizeUnitSelect := widget.NewSelect([]string{"KB", "MB", "GB"}, func(value string) {})
targetFileSizeUnitSelect.SetSelected("MB")
targetSizeManualRow := container.NewBorder(nil, nil, nil, targetFileSizeUnitSelect, targetFileSizeEntry)
parseSizeParts := func(input string) (string, string, bool) {
trimmed := strings.TrimSpace(input)
if trimmed == "" {
return "", "", false
}
upper := strings.ToUpper(trimmed)
var num float64
var unit string
if _, err := fmt.Sscanf(upper, "%f%s", &num, &unit); err != nil {
return "", "", false
}
numStr := strconv.FormatFloat(num, 'f', -1, 64)
return numStr, unit, true
}
updateTargetSizeState := func() {
val := strings.TrimSpace(targetFileSizeEntry.Text)
if val == "" {
state.convert.TargetFileSize = ""
return
}
if num, unit, ok := parseSizeParts(val); ok && unit != "" {
if num != val {
targetFileSizeEntry.SetText(num)
return
}
if unit != targetFileSizeUnitSelect.Selected {
targetFileSizeUnitSelect.SetSelected(unit)
return
}
val = num
}
unit := targetFileSizeUnitSelect.Selected
if unit == "" {
unit = "MB"
targetFileSizeUnitSelect.SetSelected(unit)
}
state.convert.TargetFileSize = val + unit
logging.Debug(logging.CatUI, "target file size set to %s", state.convert.TargetFileSize)
if buildCommandPreview != nil {
buildCommandPreview()
}
}
targetFileSizeUnitSelect.OnChanged = func(value string) {
if targetFileSizeEntry.Hidden {
return
}
updateTargetSizeState()
}
updateTargetSizeOptions := func() {
if src == nil {
@ -6010,8 +6064,17 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
targetFileSizeSelect = widget.NewSelect([]string{"Manual", "25MB", "50MB", "100MB", "200MB", "500MB", "1GB"}, func(value string) {
if value == "Manual" {
targetFileSizeEntry.Show()
targetFileSizeEntry.SetText(state.convert.TargetFileSize)
targetSizeManualRow.Show()
if state.convert.TargetFileSize != "" {
if num, unit, ok := parseSizeParts(state.convert.TargetFileSize); ok {
targetFileSizeEntry.SetText(num)
if unit != "" {
targetFileSizeUnitSelect.SetSelected(unit)
}
} else {
targetFileSizeEntry.SetText(state.convert.TargetFileSize)
}
}
} else {
// Extract size from selection (handle "XMB (Y% smaller)" format)
var sizeStr string
@ -6023,19 +6086,32 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
sizeStr = value
}
state.convert.TargetFileSize = sizeStr
targetFileSizeEntry.SetText(sizeStr)
targetFileSizeEntry.Hide()
if num, unit, ok := parseSizeParts(sizeStr); ok {
targetFileSizeEntry.SetText(num)
if unit != "" {
targetFileSizeUnitSelect.SetSelected(unit)
}
} else {
targetFileSizeEntry.SetText(sizeStr)
}
targetSizeManualRow.Hide()
}
logging.Debug(logging.CatUI, "target file size set to %s", state.convert.TargetFileSize)
})
targetFileSizeSelect.SetSelected("Manual")
updateTargetSizeOptions()
targetFileSizeEntry.SetText(state.convert.TargetFileSize)
targetFileSizeEntry.OnChanged = func(val string) {
state.convert.TargetFileSize = val
if buildCommandPreview != nil {
buildCommandPreview()
updateTargetSizeState()
}
if state.convert.TargetFileSize != "" {
if num, unit, ok := parseSizeParts(state.convert.TargetFileSize); ok {
targetFileSizeEntry.SetText(num)
if unit != "" {
targetFileSizeUnitSelect.SetSelected(unit)
}
} else {
targetFileSizeEntry.SetText(state.convert.TargetFileSize)
}
}
@ -6043,7 +6119,7 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
targetSizeContainer = container.NewVBox(
widget.NewLabelWithStyle("Target File Size", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
targetFileSizeSelect,
targetFileSizeEntry,
targetSizeManualRow,
)
encodingHint := widget.NewLabel("")
@ -7180,7 +7256,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 +10466,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 +10540,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 +10581,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 +10599,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 +10626,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 +10657,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 +10675,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 +10732,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 {