diff --git a/internal/utils/formatting.go b/internal/utils/formatting.go index 3af80b0..a67620d 100644 --- a/internal/utils/formatting.go +++ b/internal/utils/formatting.go @@ -39,6 +39,11 @@ func formatBytes(b int64) string { } } +// FormatBytes exposes human-readable bytes with binary units. +func FormatBytes(b int64) string { + return formatBytes(b) +} + // DeltaBytes renders size plus delta vs reference. func DeltaBytes(newBytes, refBytes int64) string { if newBytes <= 0 { @@ -62,7 +67,7 @@ func DeltaBitrate(newBps, refBps int) string { if newBps <= 0 { return "--" } - br := formatBitrate(newBps) + br := formatBitrateHuman(newBps) if refBps <= 0 || refBps == newBps { return br } @@ -83,3 +88,14 @@ func formatPercent(val float64) string { } return fmt.Sprintf("%.1f%%", val) } + +func formatBitrateHuman(bps int) string { + if bps <= 0 { + return "--" + } + kbps := float64(bps) / 1000.0 + if kbps >= 1000 { + return fmt.Sprintf("%.1f Mbps", kbps/1000.0) + } + return fmt.Sprintf("%.0f kbps", kbps) +} diff --git a/main.go b/main.go index 717f7eb..fe00918 100644 --- a/main.go +++ b/main.go @@ -1334,10 +1334,6 @@ func (s *appState) executeConvertJob(ctx context.Context, job *queue.Job, progre cfg := job.Config inputPath := cfg["inputPath"].(string) outputPath := cfg["outputPath"].(string) - sourceBitrate := 0 - if v, ok := cfg["sourceBitrate"].(float64); ok { - sourceBitrate = int(v) - } // If a direct conversion is running, wait until it finishes before starting queued jobs. for s.convertBusy { @@ -1517,7 +1513,7 @@ func (s *appState) executeConvertJob(ctx context.Context, job *queue.Job, progre sourceWidth, _ := cfg["sourceWidth"].(int) sourceHeight, _ := cfg["sourceHeight"].(int) // Get source bitrate if present - sourceBitrate = 0 + sourceBitrate := 0 if v, ok := cfg["sourceBitrate"].(float64); ok { sourceBitrate = int(v) } @@ -1617,6 +1613,9 @@ func (s *appState) executeConvertJob(ctx context.Context, job *queue.Job, progre } else if bitrateMode == "CBR" { if videoBitrate, _ := cfg["videoBitrate"].(string); videoBitrate != "" { args = append(args, "-b:v", videoBitrate, "-minrate", videoBitrate, "-maxrate", videoBitrate, "-bufsize", videoBitrate) + } else { + vb := defaultBitrate(videoCodec, sourceWidth, sourceBitrate) + args = append(args, "-b:v", vb, "-minrate", vb, "-maxrate", vb, "-bufsize", vb) } } else if bitrateMode == "VBR" { if videoBitrate, _ := cfg["videoBitrate"].(string); videoBitrate != "" { @@ -2865,6 +2864,30 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject { } bitratePresetSelect.SetSelected(state.convert.BitratePreset) + // Simple bitrate selector (shares presets) + simpleBitrateSelect := widget.NewSelect(bitratePresetLabels, func(value string) { + state.convert.BitratePreset = value + if applyBitratePreset != nil { + applyBitratePreset(value) + } + }) + simpleBitrateSelect.SetSelected(state.convert.BitratePreset) + + // Simple resolution selector (separate widget to avoid double-parent issues) + resolutionSelectSimple := widget.NewSelect([]string{"Source", "720p", "1080p", "1440p", "4K", "NTSC (720×480)", "PAL (720×576)"}, func(value string) { + state.convert.TargetResolution = value + logging.Debug(logging.CatUI, "target resolution set to %s (simple)", value) + }) + resolutionSelectSimple.SetSelected(state.convert.TargetResolution) + + // Simple aspect selector (separate widget) + targetAspectSelectSimple := widget.NewSelect(aspectTargets, func(value string) { + logging.Debug(logging.CatUI, "target aspect set to %s (simple)", value) + state.convert.OutputAspect = value + updateAspectBoxVisibility() + }) + targetAspectSelectSimple.SetSelected(state.convert.OutputAspect) + // Target File Size with smart presets + manual entry targetFileSizeEntry = widget.NewEntry() targetFileSizeEntry.SetPlaceHolder("e.g., 25MB, 100MB, 8MB") @@ -3028,11 +3051,14 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject { } updateEncodingControls() - // Target Resolution + // Target Resolution (advanced) resolutionSelect := widget.NewSelect([]string{"Source", "720p", "1080p", "1440p", "4K", "NTSC (720×480)", "PAL (720×576)"}, func(value string) { state.convert.TargetResolution = value logging.Debug(logging.CatUI, "target resolution set to %s", value) }) + if state.convert.TargetResolution == "" { + state.convert.TargetResolution = "Source" + } resolutionSelect.SetSelected(state.convert.TargetResolution) // Frame Rate with hint @@ -3217,7 +3243,14 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject { widget.NewLabel("Choose slower for better compression, faster for speed"), widget.NewLabelWithStyle("Encoder Preset", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), simplePresetSelect, - widget.NewLabel("Aspect ratio will match source video"), + widget.NewSeparator(), + widget.NewLabelWithStyle("Bitrate (simple presets)", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + simpleBitrateSelect, + widget.NewLabelWithStyle("Target Resolution", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + resolutionSelectSimple, + widget.NewLabelWithStyle("Target Aspect Ratio", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + targetAspectSelectSimple, + targetAspectHint, layout.NewSpacer(), ) @@ -3328,23 +3361,13 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject { tabs.OnSelected = func(item *container.TabItem) { if item.Text == "Simple" { state.convert.Mode = "Simple" - // Lock aspect ratio to Source in Simple mode - state.convert.OutputAspect = "Source" - targetAspectSelect.SetSelected("Source") - updateAspectBoxVisibility() - logging.Debug(logging.CatUI, "convert mode selected: Simple (aspect locked to Source)") + logging.Debug(logging.CatUI, "convert mode selected: Simple") } else { state.convert.Mode = "Advanced" logging.Debug(logging.CatUI, "convert mode selected: Advanced") } } - // Ensure Simple mode starts with Source aspect - if state.convert.Mode == "Simple" { - state.convert.OutputAspect = "Source" - targetAspectSelect.SetSelected("Source") - } - optionsRect := canvas.NewRectangle(utils.MustHex("#13182B")) optionsRect.CornerRadius = 8 optionsRect.StrokeColor = gridColor @@ -5307,6 +5330,7 @@ func (s *appState) startConvert(status *widget.Label, btn, cancelBtn *widget.But } src := s.source cfg := s.convert + sourceBitrate := src.Bitrate isDVD := cfg.SelectedFormat.Ext == ".mpg" outDir := filepath.Dir(src.Path) outName := cfg.OutputFile() @@ -6699,30 +6723,27 @@ func buildCompareView(state *appState) fyne.CanvasObject { // File Info section comparisonText.WriteString("━━━ FILE INFO ━━━\n") + var file1SizeBytes int64 file1Size := getField(state.compareFile1, func(src *videoSource) string { if fi, err := os.Stat(src.Path); err == nil { - sizeMB := float64(fi.Size()) / (1024 * 1024) - if sizeMB >= 1024 { - return fmt.Sprintf("%.2f GB", sizeMB/1024) - } - return fmt.Sprintf("%.2f MB", sizeMB) + file1SizeBytes = fi.Size() + return utils.FormatBytes(fi.Size()) } return "Unknown" }) file2Size := getField(state.compareFile2, func(src *videoSource) string { if fi, err := os.Stat(src.Path); err == nil { - sizeMB := float64(fi.Size()) / (1024 * 1024) - if sizeMB >= 1024 { - return fmt.Sprintf("%.2f GB", sizeMB/1024) + if file1SizeBytes > 0 { + return utils.DeltaBytes(fi.Size(), file1SizeBytes) } - return fmt.Sprintf("%.2f MB", sizeMB) + return utils.FormatBytes(fi.Size()) } return "Unknown" }) comparisonText.WriteString(fmt.Sprintf("%-25s | %-20s | %s\n", "File Size:", file1Size, file2Size)) comparisonText.WriteString(fmt.Sprintf("%-25s | %-20s | %s\n", - "Format:", + "Format Family:", getField(state.compareFile1, func(s *videoSource) string { return s.Format }), getField(state.compareFile2, func(s *videoSource) string { return s.Format }))) @@ -6747,7 +6768,12 @@ func buildCompareView(state *appState) fyne.CanvasObject { comparisonText.WriteString(fmt.Sprintf("%-25s | %-20s | %s\n", "Bitrate:", getField(state.compareFile1, func(s *videoSource) string { return formatBitrate(s.Bitrate) }), - getField(state.compareFile2, func(s *videoSource) string { return formatBitrate(s.Bitrate) }))) + getField(state.compareFile2, func(s *videoSource) string { + if state.compareFile1 != nil { + return utils.DeltaBitrate(s.Bitrate, state.compareFile1.Bitrate) + } + return formatBitrate(s.Bitrate) + }))) comparisonText.WriteString(fmt.Sprintf("%-25s | %-20s | %s\n", "Pixel Format:", getField(state.compareFile1, func(s *videoSource) string { return s.PixelFormat }), @@ -6848,22 +6874,38 @@ func buildCompareView(state *appState) fyne.CanvasObject { file2Info.Wrapping = fyne.TextWrapWord file2Info.TextStyle = fyne.TextStyle{} // non-selectable label - // Helper function to format metadata - formatMetadata := func(src *videoSource) string { - fileSize := "Unknown" + // Helper function to format metadata (optionally comparing to a reference video) + formatMetadata := func(src *videoSource, ref *videoSource) string { + var ( + fileSize = "Unknown" + refSize int64 = 0 + ) if fi, err := os.Stat(src.Path); err == nil { - sizeMB := float64(fi.Size()) / (1024 * 1024) - if sizeMB >= 1024 { - fileSize = fmt.Sprintf("%.2f GB", sizeMB/1024) + if ref != nil { + if rfi, err := os.Stat(ref.Path); err == nil { + refSize = rfi.Size() + } + } + if refSize > 0 { + fileSize = utils.DeltaBytes(fi.Size(), refSize) } else { - fileSize = fmt.Sprintf("%.2f MB", sizeMB) + fileSize = utils.FormatBytes(fi.Size()) } } - var bitrateStr string - if src.Bitrate > 0 { - bitrateStr = formatBitrate(src.Bitrate) - } else { + + var ( bitrateStr = "--" + refBitrate = 0 + ) + if ref != nil { + refBitrate = ref.Bitrate + } + if src.Bitrate > 0 { + if refBitrate > 0 { + bitrateStr = utils.DeltaBitrate(src.Bitrate, refBitrate) + } else { + bitrateStr = formatBitrate(src.Bitrate) + } } return fmt.Sprintf( @@ -6944,7 +6986,7 @@ func buildCompareView(state *appState) fyne.CanvasObject { filename := filepath.Base(state.compareFile1.Path) displayName := truncateFilename(filename, 35) file1Label.SetText(fmt.Sprintf("File 1: %s", displayName)) - file1Info.SetText(formatMetadata(state.compareFile1)) + file1Info.SetText(formatMetadata(state.compareFile1, state.compareFile2)) // Build video player with compact size for side-by-side file1VideoContainer.Objects = []fyne.CanvasObject{ buildVideoPane(state, fyne.NewSize(320, 180), state.compareFile1, nil), @@ -6965,7 +7007,7 @@ func buildCompareView(state *appState) fyne.CanvasObject { filename := filepath.Base(state.compareFile2.Path) displayName := truncateFilename(filename, 35) file2Label.SetText(fmt.Sprintf("File 2: %s", displayName)) - file2Info.SetText(formatMetadata(state.compareFile2)) + file2Info.SetText(formatMetadata(state.compareFile2, state.compareFile1)) // Build video player with compact size for side-by-side file2VideoContainer.Objects = []fyne.CanvasObject{ buildVideoPane(state, fyne.NewSize(320, 180), state.compareFile2, nil), @@ -7030,7 +7072,7 @@ func buildCompareView(state *appState) fyne.CanvasObject { if state.compareFile1 == nil { return } - metadata := formatMetadata(state.compareFile1) + metadata := formatMetadata(state.compareFile1, state.compareFile2) state.window.Clipboard().SetContent(metadata) dialog.ShowInformation("Copied", "Metadata copied to clipboard", state.window) }) @@ -7047,7 +7089,7 @@ func buildCompareView(state *appState) fyne.CanvasObject { if state.compareFile2 == nil { return } - metadata := formatMetadata(state.compareFile2) + metadata := formatMetadata(state.compareFile2, state.compareFile1) state.window.Clipboard().SetContent(metadata) dialog.ShowInformation("Copied", "Metadata copied to clipboard", state.window) }) @@ -7159,19 +7201,14 @@ func buildInspectView(state *appState) fyne.CanvasObject { formatMetadata := func(src *videoSource) string { fileSize := "Unknown" if fi, err := os.Stat(src.Path); err == nil { - sizeMB := float64(fi.Size()) / (1024 * 1024) - if sizeMB >= 1024 { - fileSize = fmt.Sprintf("%.2f GB", sizeMB/1024) - } else { - fileSize = fmt.Sprintf("%.2f MB", sizeMB) - } + fileSize = utils.FormatBytes(fi.Size()) } return fmt.Sprintf( "━━━ FILE INFO ━━━\n"+ "Path: %s\n"+ "File Size: %s\n"+ - "Format: %s\n"+ + "Format Family: %s\n"+ "\n━━━ VIDEO ━━━\n"+ "Codec: %s\n"+ "Resolution: %dx%d\n"+ diff --git a/scripts/build-linux.sh b/scripts/build-linux.sh index aa72af8..2046d74 100755 --- a/scripts/build-linux.sh +++ b/scripts/build-linux.sh @@ -6,8 +6,8 @@ set -e PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" BUILD_OUTPUT="$PROJECT_ROOT/VideoTools" -# Extract app version from main.go -APP_VERSION="$(grep -E 'appVersion\s*=\s*\"' "$PROJECT_ROOT/main.go" | head -1 | sed -E 's/.*"([^"]+)".*/\1/')" +# Extract app version from main.go (avoid grep warnings on Git Bash) +APP_VERSION="$(grep -m1 'appVersion' "$PROJECT_ROOT/main.go" | sed -E 's/.*\"([^\"]+)\".*/\1/')" [ -z "$APP_VERSION" ] && APP_VERSION="(version unknown)" echo "════════════════════════════════════════════════════════════════" @@ -64,5 +64,10 @@ if go build -o "$BUILD_OUTPUT" .; then else echo "❌ Build failed! (VideoTools $APP_VERSION)" echo "Diagnostics: version=$APP_VERSION os=$(uname -s) arch=$(uname -m) go=$(go version | awk '{print $3}')" + echo "" + echo "Help: check the Go error messages above." + echo " - Undefined symbol/identifier: usually a missing variable or typo in source; see the referenced file:line." + echo " - \"C compiler not found\": install a C toolchain (e.g., build-essential on Ubuntu, Xcode CLT on macOS)." + echo " - Cache permission denied: run 'rm -rf ~/.cache/go-build' or 'chown -R $USER ~/.cache/go-build'." exit 1 fi diff --git a/scripts/build.sh b/scripts/build.sh index 31dd15f..7fa0e2f 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -5,8 +5,8 @@ set -e PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# Extract app version from main.go -APP_VERSION="$(grep -E 'appVersion[[:space:]]*=[[:space:]]*\"' "$PROJECT_ROOT/main.go" | head -1 | sed -E 's/.*\"([^\"]+)\".*/\1/')" +# Extract app version from main.go (avoid grep warnings on Git Bash) +APP_VERSION="$(grep -m1 'appVersion' "$PROJECT_ROOT/main.go" | sed -E 's/.*\"([^\"]+)\".*/\1/')" [ -z "$APP_VERSION" ] && APP_VERSION="(version unknown)" echo "════════════════════════════════════════════════════════════════" @@ -97,6 +97,11 @@ case "$OS" in else echo "❌ Build failed! (VideoTools $APP_VERSION)" diagnostics + echo "" + echo "Help: check the Go error messages above." + echo " - Undefined symbol/identifier: usually a missing variable or typo in source; see the referenced file:line." + echo " - \"C compiler not found\": install MinGW-w64 or MSYS2 toolchain so gcc is in PATH." + echo " - Cache permission denied: delete or chown the Go build cache (e.g., %LOCALAPPDATA%\\go-build on Windows)." exit 1 fi ;; diff --git a/scripts/clear-go-cache.sh b/scripts/clear-go-cache.sh new file mode 100644 index 0000000..970855e --- /dev/null +++ b/scripts/clear-go-cache.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# Convenience script to clear Go build/module caches. +# Safe to run on Linux/macOS and Windows Git Bash. + +set -e + +echo "════════════════════════════════════════════════════════════════" +echo " VideoTools Go Cache Cleaner" +echo "════════════════════════════════════════════════════════════════" +echo "" + +if ! command -v go >/dev/null 2>&1; then + echo "⚠️ Go is not installed or not in PATH; skipping go clean." +else + echo "🧹 Running: go clean -cache -modcache -testcache" + go clean -cache -modcache -testcache || true + echo "✓ Go clean complete" +fi + +OS="$(uname -s)" +case "$OS" in + CYGWIN*|MINGW*|MSYS*) + # Windows paths under Git Bash + CACHE_DIR="${LOCALAPPDATA:-$APPDATA}/go-build" + ;; + *) + CACHE_DIR="${GOCACHE:-$HOME/.cache/go-build}" + ;; +esac + +if [ -n "$CACHE_DIR" ] && [ -d "$CACHE_DIR" ]; then + echo "🗑️ Removing build cache dir: $CACHE_DIR" + rm -rf "$CACHE_DIR" || sudo rm -rf "$CACHE_DIR" || true +else + echo "ℹ️ No cache directory found at $CACHE_DIR (nothing to remove)." +fi + +echo "" +echo "✅ Done. Re-run ./scripts/build.sh to rebuild VideoTools."