Compare commits
2 commits
5b76da0fdf
...
121a61d627
| Author | SHA1 | Date | |
|---|---|---|---|
| 121a61d627 | |||
| 43efc84bf6 |
4 changed files with 175 additions and 14 deletions
2
DONE.md
2
DONE.md
|
|
@ -766,6 +766,8 @@ This file tracks completed features, fixes, and milestones.
|
||||||
### Recent Fixes
|
### Recent Fixes
|
||||||
- ✅ Fixed aspect ratio default from 16:9 to Source (dev7)
|
- ✅ Fixed aspect ratio default from 16:9 to Source (dev7)
|
||||||
- ✅ Ranked benchmark results by score and added cancel confirmation
|
- ✅ 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
|
- ✅ Stabilized video seeking and embedded rendering
|
||||||
- ✅ Improved player window positioning
|
- ✅ Improved player window positioning
|
||||||
- ✅ Fixed clear video functionality
|
- ✅ Fixed clear video functionality
|
||||||
|
|
|
||||||
2
TODO.md
2
TODO.md
|
|
@ -41,6 +41,8 @@ This file tracks upcoming features, improvements, and known issues.
|
||||||
- Lossless option only for H.265/AV1
|
- Lossless option only for H.265/AV1
|
||||||
- Dynamic dropdown based on codec
|
- Dynamic dropdown based on codec
|
||||||
- Lossless + Target Size mode support
|
- Lossless + Target Size mode support
|
||||||
|
- Audio bitrate estimation when metadata is missing
|
||||||
|
- Target size unit selector and numeric entry
|
||||||
|
|
||||||
## Priority Features for dev20+
|
## Priority Features for dev20+
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -217,15 +217,18 @@ func ParseFileSize(sizeStr string) (int64, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("invalid size format: %s", sizeStr)
|
return 0, fmt.Errorf("invalid size format: %s", sizeStr)
|
||||||
}
|
}
|
||||||
|
if unit == "" {
|
||||||
|
unit = "MB"
|
||||||
|
}
|
||||||
|
|
||||||
// Convert to bytes
|
// Convert to bytes
|
||||||
multiplier := int64(1)
|
multiplier := int64(1)
|
||||||
switch unit {
|
switch unit {
|
||||||
case "KB":
|
case "K", "KB":
|
||||||
multiplier = 1024
|
multiplier = 1024
|
||||||
case "MB":
|
case "M", "MB":
|
||||||
multiplier = 1024 * 1024
|
multiplier = 1024 * 1024
|
||||||
case "GB":
|
case "G", "GB":
|
||||||
multiplier = 1024 * 1024 * 1024
|
multiplier = 1024 * 1024 * 1024
|
||||||
case "B", "":
|
case "B", "":
|
||||||
multiplier = 1
|
multiplier = 1
|
||||||
|
|
|
||||||
176
main.go
176
main.go
|
|
@ -5966,7 +5966,61 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
|
||||||
|
|
||||||
// Target File Size with smart presets + manual entry
|
// Target File Size with smart presets + manual entry
|
||||||
targetFileSizeEntry = widget.NewEntry()
|
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() {
|
updateTargetSizeOptions := func() {
|
||||||
if src == nil {
|
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) {
|
targetFileSizeSelect = widget.NewSelect([]string{"Manual", "25MB", "50MB", "100MB", "200MB", "500MB", "1GB"}, func(value string) {
|
||||||
if value == "Manual" {
|
if value == "Manual" {
|
||||||
targetFileSizeEntry.Show()
|
targetSizeManualRow.Show()
|
||||||
targetFileSizeEntry.SetText(state.convert.TargetFileSize)
|
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 {
|
} else {
|
||||||
// Extract size from selection (handle "XMB (Y% smaller)" format)
|
// Extract size from selection (handle "XMB (Y% smaller)" format)
|
||||||
var sizeStr string
|
var sizeStr string
|
||||||
|
|
@ -6023,19 +6086,32 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
|
||||||
sizeStr = value
|
sizeStr = value
|
||||||
}
|
}
|
||||||
state.convert.TargetFileSize = sizeStr
|
state.convert.TargetFileSize = sizeStr
|
||||||
targetFileSizeEntry.SetText(sizeStr)
|
if num, unit, ok := parseSizeParts(sizeStr); ok {
|
||||||
targetFileSizeEntry.Hide()
|
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)
|
logging.Debug(logging.CatUI, "target file size set to %s", state.convert.TargetFileSize)
|
||||||
})
|
})
|
||||||
targetFileSizeSelect.SetSelected("Manual")
|
targetFileSizeSelect.SetSelected("Manual")
|
||||||
updateTargetSizeOptions()
|
updateTargetSizeOptions()
|
||||||
|
|
||||||
targetFileSizeEntry.SetText(state.convert.TargetFileSize)
|
|
||||||
targetFileSizeEntry.OnChanged = func(val string) {
|
targetFileSizeEntry.OnChanged = func(val string) {
|
||||||
state.convert.TargetFileSize = val
|
updateTargetSizeState()
|
||||||
if buildCommandPreview != nil {
|
}
|
||||||
buildCommandPreview()
|
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(
|
targetSizeContainer = container.NewVBox(
|
||||||
widget.NewLabelWithStyle("Target File Size", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
widget.NewLabelWithStyle("Target File Size", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||||
targetFileSizeSelect,
|
targetFileSizeSelect,
|
||||||
targetFileSizeEntry,
|
targetSizeManualRow,
|
||||||
)
|
)
|
||||||
|
|
||||||
encodingHint := widget.NewLabel("")
|
encodingHint := widget.NewLabel("")
|
||||||
|
|
@ -7180,7 +7256,11 @@ func buildMetadataPanel(state *appState, src *videoSource, min fyne.Size) (fyne.
|
||||||
|
|
||||||
audioBitrate := "--"
|
audioBitrate := "--"
|
||||||
if src.AudioBitrate > 0 {
|
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
|
// Format advanced metadata
|
||||||
|
|
@ -10386,6 +10466,7 @@ type videoSource struct {
|
||||||
AudioCodec string
|
AudioCodec string
|
||||||
Bitrate int // Video bitrate in bits per second
|
Bitrate int // Video bitrate in bits per second
|
||||||
AudioBitrate int // Audio bitrate in bits per second
|
AudioBitrate int // Audio bitrate in bits per second
|
||||||
|
AudioBitrateEstimated bool
|
||||||
FrameRate float64
|
FrameRate float64
|
||||||
PixelFormat string
|
PixelFormat string
|
||||||
AudioRate int
|
AudioRate int
|
||||||
|
|
@ -10459,6 +10540,11 @@ func probeVideo(path string) (*videoSource, error) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
var fileSize int64
|
||||||
|
if info, err := os.Stat(path); err == nil {
|
||||||
|
fileSize = info.Size()
|
||||||
|
}
|
||||||
|
|
||||||
cmd := exec.CommandContext(ctx, "ffprobe",
|
cmd := exec.CommandContext(ctx, "ffprobe",
|
||||||
"-v", "quiet",
|
"-v", "quiet",
|
||||||
"-print_format", "json",
|
"-print_format", "json",
|
||||||
|
|
@ -10495,6 +10581,7 @@ func probeVideo(path string) (*videoSource, error) {
|
||||||
Channels int `json:"channels"`
|
Channels int `json:"channels"`
|
||||||
AvgFrameRate string `json:"avg_frame_rate"`
|
AvgFrameRate string `json:"avg_frame_rate"`
|
||||||
FieldOrder string `json:"field_order"`
|
FieldOrder string `json:"field_order"`
|
||||||
|
Tags map[string]interface{} `json:"tags"`
|
||||||
Disposition struct {
|
Disposition struct {
|
||||||
AttachedPic int `json:"attached_pic"`
|
AttachedPic int `json:"attached_pic"`
|
||||||
} `json:"disposition"`
|
} `json:"disposition"`
|
||||||
|
|
@ -10512,7 +10599,9 @@ func probeVideo(path string) (*videoSource, error) {
|
||||||
DisplayName: filepath.Base(path),
|
DisplayName: filepath.Base(path),
|
||||||
Format: utils.FirstNonEmpty(result.Format.Format, result.Format.FormatName),
|
Format: utils.FirstNonEmpty(result.Format.Format, result.Format.FormatName),
|
||||||
}
|
}
|
||||||
|
var formatBitrate int
|
||||||
if rate, err := utils.ParseInt(result.Format.BitRate); err == nil {
|
if rate, err := utils.ParseInt(result.Format.BitRate); err == nil {
|
||||||
|
formatBitrate = rate
|
||||||
src.Bitrate = rate
|
src.Bitrate = rate
|
||||||
}
|
}
|
||||||
if durStr := result.Format.Duration; durStr != "" {
|
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)
|
// Track if we've found the main video stream (not cover art)
|
||||||
foundMainVideo := false
|
foundMainVideo := false
|
||||||
var coverArtStreamIndex int = -1
|
var coverArtStreamIndex int = -1
|
||||||
|
var videoStreamBitrate int
|
||||||
|
|
||||||
for _, stream := range result.Streams {
|
for _, stream := range result.Streams {
|
||||||
switch stream.CodecType {
|
switch stream.CodecType {
|
||||||
|
|
@ -10567,6 +10657,9 @@ func probeVideo(path string) (*videoSource, error) {
|
||||||
if stream.PixFmt != "" {
|
if stream.PixFmt != "" {
|
||||||
src.PixelFormat = stream.PixFmt
|
src.PixelFormat = stream.PixFmt
|
||||||
}
|
}
|
||||||
|
if br, err := utils.ParseInt(stream.BitRate); err == nil && br > 0 {
|
||||||
|
videoStreamBitrate = br
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if src.Bitrate == 0 {
|
if src.Bitrate == 0 {
|
||||||
if br, err := utils.ParseInt(stream.BitRate); err == nil {
|
if br, err := utils.ParseInt(stream.BitRate); err == nil {
|
||||||
|
|
@ -10582,10 +10675,41 @@ func probeVideo(path string) (*videoSource, error) {
|
||||||
if stream.Channels > 0 {
|
if stream.Channels > 0 {
|
||||||
src.Channels = stream.Channels
|
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
|
// Extract embedded cover art if present
|
||||||
if coverArtStreamIndex >= 0 {
|
if coverArtStreamIndex >= 0 {
|
||||||
coverPath := filepath.Join(os.TempDir(), fmt.Sprintf("videotools-embedded-cover-%d.png", time.Now().UnixNano()))
|
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
|
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 {
|
func normalizeTags(tags map[string]interface{}) map[string]string {
|
||||||
normalized := make(map[string]string, len(tags))
|
normalized := make(map[string]string, len(tags))
|
||||||
for k, v := range tags {
|
for k, v := range tags {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue