Add target file size feature and fix multiple encoding issues

- Add TargetFileSize mode with automatic bitrate calculation
- Add CalculateBitrateForTargetSize and ParseFileSize utility functions
- Fix NVENC hardware encoding (remove incorrect -hwaccel cuda flag)
- Fix auto-detection override when hardware accel set to none
- Fix 10-bit pixel format incompatibility (change to 8-bit yuv420p)
- Add enhanced video metadata display (PAR, color space, GOP size, audio bitrate, chapters)
- Improve error reporting with FFmpeg stderr capture and exit code interpretation
- Add interpretFFmpegError function for human-readable error messages
This commit is contained in:
Stu Leak 2025-12-03 10:00:14 -05:00
parent 292da5c59e
commit 6a2f1fff3f
4 changed files with 401 additions and 41 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 45 KiB

View File

@ -7,6 +7,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"git.leaktechnologies.dev/stu/VideoTools/internal/logging" "git.leaktechnologies.dev/stu/VideoTools/internal/logging"
@ -96,26 +97,34 @@ func ProbeVideo(path string) (*VideoSource, error) {
var result struct { var result struct {
Format struct { Format struct {
Filename string `json:"filename"` Filename string `json:"filename"`
Format string `json:"format_long_name"` Format string `json:"format_long_name"`
Duration string `json:"duration"` Duration string `json:"duration"`
FormatName string `json:"format_name"` FormatName string `json:"format_name"`
BitRate string `json:"bit_rate"` BitRate string `json:"bit_rate"`
Tags map[string]interface{} `json:"tags"`
} `json:"format"` } `json:"format"`
Streams []struct { Chapters []interface{} `json:"chapters"`
Index int `json:"index"` Streams []struct {
CodecType string `json:"codec_type"` Index int `json:"index"`
CodecName string `json:"codec_name"` CodecType string `json:"codec_type"`
Width int `json:"width"` CodecName string `json:"codec_name"`
Height int `json:"height"` Width int `json:"width"`
Duration string `json:"duration"` Height int `json:"height"`
BitRate string `json:"bit_rate"` Duration string `json:"duration"`
PixFmt string `json:"pix_fmt"` BitRate string `json:"bit_rate"`
SampleRate string `json:"sample_rate"` PixFmt string `json:"pix_fmt"`
Channels int `json:"channels"` SampleRate string `json:"sample_rate"`
AvgFrameRate string `json:"avg_frame_rate"` Channels int `json:"channels"`
FieldOrder string `json:"field_order"` AvgFrameRate string `json:"avg_frame_rate"`
Disposition struct { FieldOrder string `json:"field_order"`
SampleAspectRat string `json:"sample_aspect_ratio"`
DisplayAspect string `json:"display_aspect_ratio"`
ColorSpace string `json:"color_space"`
ColorRange string `json:"color_range"`
ColorPrimaries string `json:"color_primaries"`
ColorTransfer string `json:"color_transfer"`
Disposition struct {
AttachedPic int `json:"attached_pic"` AttachedPic int `json:"attached_pic"`
} `json:"disposition"` } `json:"disposition"`
} `json:"streams"` } `json:"streams"`
@ -137,6 +146,22 @@ func ProbeVideo(path string) (*VideoSource, error) {
src.Duration = val src.Duration = val
} }
} }
// Check for chapters
src.HasChapters = len(result.Chapters) > 0
// Check for metadata (title, artist, copyright, etc.)
if result.Format.Tags != nil && len(result.Format.Tags) > 0 {
// Look for common metadata tags
for key := range result.Format.Tags {
lowerKey := strings.ToLower(key)
if lowerKey == "title" || lowerKey == "artist" || lowerKey == "copyright" ||
lowerKey == "comment" || lowerKey == "description" || lowerKey == "album" {
src.HasMetadata = true
break
}
}
}
// 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
@ -170,6 +195,23 @@ func ProbeVideo(path string) (*VideoSource, error) {
if stream.PixFmt != "" { if stream.PixFmt != "" {
src.PixelFormat = stream.PixFmt src.PixelFormat = stream.PixFmt
} }
// Capture additional metadata
if stream.SampleAspectRat != "" && stream.SampleAspectRat != "0:1" {
src.SampleAspectRatio = stream.SampleAspectRat
}
// Color space information
if stream.ColorSpace != "" && stream.ColorSpace != "unknown" {
src.ColorSpace = stream.ColorSpace
} else if stream.ColorPrimaries != "" && stream.ColorPrimaries != "unknown" {
// Fallback to color primaries if color_space is not set
src.ColorSpace = stream.ColorPrimaries
}
if stream.ColorRange != "" && stream.ColorRange != "unknown" {
src.ColorRange = stream.ColorRange
}
} }
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 {
@ -185,6 +227,9 @@ 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
}
} }
} }
} }
@ -207,5 +252,62 @@ func ProbeVideo(path string) (*VideoSource, error) {
} }
} }
// Probe GOP size by examining a few frames (only if we have video)
if foundMainVideo && src.Duration > 0 {
gopSize := detectGOPSize(ctx, path)
if gopSize > 0 {
src.GOPSize = gopSize
}
}
return src, nil return src, nil
} }
// detectGOPSize attempts to detect GOP size by examining key frames
func detectGOPSize(ctx context.Context, path string) int {
// Use ffprobe to show frames and look for key_frame markers
// We'll analyze the first 300 frames (about 10 seconds at 30fps)
cmd := exec.CommandContext(ctx, "ffprobe",
"-v", "quiet",
"-select_streams", "v:0",
"-show_entries", "frame=pict_type,key_frame",
"-read_intervals", "%+#300",
"-print_format", "json",
path,
)
out, err := cmd.Output()
if err != nil {
return 0
}
var result struct {
Frames []struct {
KeyFrame int `json:"key_frame"`
PictType string `json:"pict_type"`
} `json:"frames"`
}
if err := json.Unmarshal(out, &result); err != nil {
return 0
}
// Find distances between key frames
var keyFramePositions []int
for i, frame := range result.Frames {
if frame.KeyFrame == 1 {
keyFramePositions = append(keyFramePositions, i)
}
}
// Calculate average GOP size
if len(keyFramePositions) >= 2 {
var totalDistance int
for i := 1; i < len(keyFramePositions); i++ {
totalDistance += keyFramePositions[i] - keyFramePositions[i-1]
}
return totalDistance / (len(keyFramePositions) - 1)
}
return 0
}

View File

@ -28,8 +28,9 @@ type ConvertConfig struct {
VideoCodec string // H.264, H.265, VP9, AV1, Copy VideoCodec string // H.264, H.265, VP9, AV1, Copy
EncoderPreset string // ultrafast, superfast, veryfast, faster, fast, medium, slow, slower, veryslow EncoderPreset string // ultrafast, superfast, veryfast, faster, fast, medium, slow, slower, veryslow
CRF string // Manual CRF value (0-51, or empty to use Quality preset) CRF string // Manual CRF value (0-51, or empty to use Quality preset)
BitrateMode string // CRF, CBR, VBR BitrateMode string // CRF, CBR, VBR, TargetSize
VideoBitrate string // For CBR/VBR modes (e.g., "5000k") VideoBitrate string // For CBR/VBR modes (e.g., "5000k")
TargetFileSize string // Target file size (e.g., "25MB", "100MB", "8MB") - requires BitrateMode=TargetSize
TargetResolution string // Source, 720p, 1080p, 1440p, 4K, or custom TargetResolution string // Source, 720p, 1080p, 1440p, 4K, or custom
FrameRate string // Source, 24, 30, 60, or custom FrameRate string // Source, 24, 30, 60, or custom
PixelFormat string // yuv420p, yuv422p, yuv444p PixelFormat string // yuv420p, yuv422p, yuv444p
@ -76,7 +77,8 @@ type VideoSource struct {
Duration float64 Duration float64
VideoCodec string VideoCodec string
AudioCodec string AudioCodec string
Bitrate int Bitrate int // Video bitrate in bits per second
AudioBitrate int // Audio bitrate in bits per second
FrameRate float64 FrameRate float64
PixelFormat string PixelFormat string
AudioRate int AudioRate int
@ -84,6 +86,14 @@ type VideoSource struct {
FieldOrder string FieldOrder string
PreviewFrames []string PreviewFrames []string
EmbeddedCoverArt string // Path to extracted embedded cover art, if any EmbeddedCoverArt string // Path to extracted embedded cover art, if any
// Advanced metadata
SampleAspectRatio string // Pixel Aspect Ratio (SAR) - e.g., "1:1", "40:33"
ColorSpace string // Color space/primaries - e.g., "bt709", "bt601"
ColorRange string // Color range - "tv" (limited) or "pc" (full)
GOPSize int // GOP size / keyframe interval
HasChapters bool // Whether file has embedded chapters
HasMetadata bool // Whether file has title/copyright/etc metadata
} }
// DurationString returns a human-readable duration string (HH:MM:SS or MM:SS) // DurationString returns a human-readable duration string (HH:MM:SS or MM:SS)
@ -155,6 +165,76 @@ func ResolveTargetAspect(val string, src *VideoSource) float64 {
return 0 return 0
} }
// CalculateBitrateForTargetSize calculates the required video bitrate to hit a target file size
// targetSize: target file size in bytes
// duration: video duration in seconds
// audioBitrate: audio bitrate in bits per second
// Returns: video bitrate in bits per second
func CalculateBitrateForTargetSize(targetSize int64, duration float64, audioBitrate int) int {
if duration <= 0 {
return 0
}
// Reserve 3% for container overhead
targetSize = int64(float64(targetSize) * 0.97)
// Calculate total bits available
totalBits := targetSize * 8
// Calculate audio bits
audioBits := int64(float64(audioBitrate) * duration)
// Remaining bits for video
videoBits := totalBits - audioBits
if videoBits < 0 {
videoBits = totalBits / 2 // Fallback: split 50/50 if audio is too large
}
// Calculate video bitrate
videoBitrate := int(float64(videoBits) / duration)
// Minimum bitrate sanity check (100 kbps)
if videoBitrate < 100000 {
videoBitrate = 100000
}
return videoBitrate
}
// ParseFileSize parses a file size string like "25MB", "100MB", "1.5GB" into bytes
func ParseFileSize(sizeStr string) (int64, error) {
sizeStr = strings.TrimSpace(strings.ToUpper(sizeStr))
if sizeStr == "" {
return 0, fmt.Errorf("empty size string")
}
// Extract number and unit
var value float64
var unit string
_, err := fmt.Sscanf(sizeStr, "%f%s", &value, &unit)
if err != nil {
return 0, fmt.Errorf("invalid size format: %s", sizeStr)
}
// Convert to bytes
multiplier := int64(1)
switch unit {
case "KB":
multiplier = 1024
case "MB":
multiplier = 1024 * 1024
case "GB":
multiplier = 1024 * 1024 * 1024
case "B", "":
multiplier = 1
default:
return 0, fmt.Errorf("unknown unit: %s", unit)
}
return int64(value * float64(multiplier)), nil
}
// AspectFilters returns FFmpeg filter strings for aspect ratio conversion // AspectFilters returns FFmpeg filter strings for aspect ratio conversion
func AspectFilters(target float64, mode string) []string { func AspectFilters(target float64, mode string) []string {
if target <= 0 { if target <= 0 {

218
main.go
View File

@ -117,8 +117,9 @@ type convertConfig struct {
VideoCodec string // H.264, H.265, VP9, AV1, Copy VideoCodec string // H.264, H.265, VP9, AV1, Copy
EncoderPreset string // ultrafast, superfast, veryfast, faster, fast, medium, slow, slower, veryslow EncoderPreset string // ultrafast, superfast, veryfast, faster, fast, medium, slow, slower, veryslow
CRF string // Manual CRF value (0-51, or empty to use Quality preset) CRF string // Manual CRF value (0-51, or empty to use Quality preset)
BitrateMode string // CRF, CBR, VBR BitrateMode string // CRF, CBR, VBR, TargetSize
VideoBitrate string // For CBR/VBR modes (e.g., "5000k") VideoBitrate string // For CBR/VBR modes (e.g., "5000k")
TargetFileSize string // Target file size (e.g., "25MB", "100MB") - requires BitrateMode=TargetSize
TargetResolution string // Source, 720p, 1080p, 1440p, 4K, or custom TargetResolution string // Source, 720p, 1080p, 1440p, 4K, or custom
FrameRate string // Source, 24, 30, 60, or custom FrameRate string // Source, 24, 30, 60, or custom
PixelFormat string // yuv420p, yuv422p, yuv444p PixelFormat string // yuv420p, yuv422p, yuv444p
@ -946,12 +947,15 @@ func (s *appState) executeConvertJob(ctx context.Context, job *queue.Job, progre
args = append(args, "-i", coverArtPath) args = append(args, "-i", coverArtPath)
} }
// Hardware acceleration // Hardware acceleration for decoding
// Note: NVENC doesn't need -hwaccel for encoding, only for decoding
hardwareAccel, _ := cfg["hardwareAccel"].(string) hardwareAccel, _ := cfg["hardwareAccel"].(string)
if hardwareAccel != "none" && hardwareAccel != "" { if hardwareAccel != "none" && hardwareAccel != "" {
switch hardwareAccel { switch hardwareAccel {
case "nvenc": case "nvenc":
args = append(args, "-hwaccel", "cuda") // For NVENC, we don't add -hwaccel flags
// The h264_nvenc/hevc_nvenc encoder handles GPU encoding directly
// Only add hwaccel if we want GPU decoding too, which can cause issues
case "vaapi": case "vaapi":
args = append(args, "-hwaccel", "vaapi") args = append(args, "-hwaccel", "vaapi")
case "qsv": case "qsv":
@ -1210,6 +1214,9 @@ func (s *appState) executeConvertJob(ctx context.Context, job *queue.Job, progre
logging.Debug(logging.CatFFMPEG, "queue convert command: ffmpeg %s", strings.Join(args, " ")) logging.Debug(logging.CatFFMPEG, "queue convert command: ffmpeg %s", strings.Join(args, " "))
// Also print to stdout for debugging
fmt.Printf("\n=== FFMPEG COMMAND ===\nffmpeg %s\n======================\n\n", strings.Join(args, " "))
// Execute FFmpeg // Execute FFmpeg
cmd := exec.CommandContext(ctx, "ffmpeg", args...) cmd := exec.CommandContext(ctx, "ffmpeg", args...)
stdout, err := cmd.StdoutPipe() stdout, err := cmd.StdoutPipe()
@ -1217,6 +1224,10 @@ func (s *appState) executeConvertJob(ctx context.Context, job *queue.Job, progre
return fmt.Errorf("failed to create stdout pipe: %w", err) return fmt.Errorf("failed to create stdout pipe: %w", err)
} }
// Capture stderr for error messages
var stderrBuf strings.Builder
cmd.Stderr = &stderrBuf
if err := cmd.Start(); err != nil { if err := cmd.Start(); err != nil {
return fmt.Errorf("failed to start ffmpeg: %w", err) return fmt.Errorf("failed to start ffmpeg: %w", err)
} }
@ -1250,7 +1261,35 @@ func (s *appState) executeConvertJob(ctx context.Context, job *queue.Job, progre
} }
if err := cmd.Wait(); err != nil { if err := cmd.Wait(); err != nil {
return fmt.Errorf("ffmpeg failed: %w", err) stderrOutput := stderrBuf.String()
errorExplanation := interpretFFmpegError(err)
// Check if this is a hardware encoding failure
isHardwareFailure := strings.Contains(stderrOutput, "No capable devices found") ||
strings.Contains(stderrOutput, "Cannot load") ||
strings.Contains(stderrOutput, "not available") &&
(strings.Contains(stderrOutput, "nvenc") ||
strings.Contains(stderrOutput, "qsv") ||
strings.Contains(stderrOutput, "vaapi") ||
strings.Contains(stderrOutput, "videotoolbox"))
if isHardwareFailure && hardwareAccel != "none" && hardwareAccel != "" {
logging.Debug(logging.CatFFMPEG, "hardware encoding failed, will suggest software fallback")
return fmt.Errorf("hardware encoding (%s) failed - no compatible hardware found\n\nPlease disable hardware acceleration in the conversion settings and try again with software encoding.\n\nFFmpeg output:\n%s", hardwareAccel, stderrOutput)
}
var errorMsg string
if errorExplanation != "" {
errorMsg = fmt.Sprintf("ffmpeg failed: %v - %s", err, errorExplanation)
} else {
errorMsg = fmt.Sprintf("ffmpeg failed: %v", err)
}
if stderrOutput != "" {
logging.Debug(logging.CatFFMPEG, "ffmpeg stderr: %s", stderrOutput)
return fmt.Errorf("%s\n\nFFmpeg output:\n%s", errorMsg, stderrOutput)
}
return fmt.Errorf("%s", errorMsg)
} }
logging.Debug(logging.CatFFMPEG, "queue conversion completed: %s", outputPath) logging.Debug(logging.CatFFMPEG, "queue conversion completed: %s", outputPath)
@ -1362,7 +1401,7 @@ func runGUI() {
VideoBitrate: "5000k", VideoBitrate: "5000k",
TargetResolution: "Source", TargetResolution: "Source",
FrameRate: "Source", FrameRate: "Source",
PixelFormat: "yuv420p10le", PixelFormat: "yuv420p",
HardwareAccel: "none", HardwareAccel: "none",
TwoPass: false, TwoPass: false,
H264Profile: "main", H264Profile: "main",
@ -2264,33 +2303,88 @@ func buildMetadataPanel(state *appState, src *videoSource, min fyne.Size) (fyne.
bitrate = fmt.Sprintf("%d kbps", src.Bitrate/1000) bitrate = fmt.Sprintf("%d kbps", src.Bitrate/1000)
} }
audioBitrate := "--"
if src.AudioBitrate > 0 {
audioBitrate = fmt.Sprintf("%d kbps", src.AudioBitrate/1000)
}
// Format advanced metadata
par := utils.FirstNonEmpty(src.SampleAspectRatio, "1:1 (Square)")
if par == "1:1" || par == "1:1 (Square)" {
par = "1:1 (Square)"
} else {
par = par + " (Non-square)"
}
colorSpace := utils.FirstNonEmpty(src.ColorSpace, "Unknown")
colorRange := utils.FirstNonEmpty(src.ColorRange, "Unknown")
if colorRange == "tv" {
colorRange = "Limited (TV)"
} else if colorRange == "pc" || colorRange == "jpeg" {
colorRange = "Full (PC)"
}
interlacing := "Progressive"
if src.FieldOrder != "" && src.FieldOrder != "progressive" && src.FieldOrder != "unknown" {
interlacing = "Interlaced (" + src.FieldOrder + ")"
}
gopSize := "--"
if src.GOPSize > 0 {
gopSize = fmt.Sprintf("%d frames", src.GOPSize)
}
chapters := "No"
if src.HasChapters {
chapters = "Yes"
}
metadata := "No"
if src.HasMetadata {
metadata = "Yes (title/copyright/etc)"
}
// Build metadata string for copying // Build metadata string for copying
metadataText := fmt.Sprintf(`File: %s metadataText := fmt.Sprintf(`File: %s
Format: %s Format: %s
Resolution: %dx%d Resolution: %dx%d
Aspect Ratio: %s Aspect Ratio: %s
Pixel Aspect Ratio: %s
Duration: %s Duration: %s
Video Codec: %s Video Codec: %s
Video Bitrate: %s Video Bitrate: %s
Frame Rate: %.2f fps Frame Rate: %.2f fps
Pixel Format: %s Pixel Format: %s
Field Order: %s Interlacing: %s
Color Space: %s
Color Range: %s
GOP Size: %s
Audio Codec: %s Audio Codec: %s
Audio Bitrate: %s
Audio Rate: %d Hz Audio Rate: %d Hz
Channels: %s`, Channels: %s
Chapters: %s
Metadata: %s`,
src.DisplayName, src.DisplayName,
utils.FirstNonEmpty(src.Format, "Unknown"), utils.FirstNonEmpty(src.Format, "Unknown"),
src.Width, src.Height, src.Width, src.Height,
src.AspectRatioString(), src.AspectRatioString(),
par,
src.DurationString(), src.DurationString(),
utils.FirstNonEmpty(src.VideoCodec, "Unknown"), utils.FirstNonEmpty(src.VideoCodec, "Unknown"),
bitrate, bitrate,
src.FrameRate, src.FrameRate,
utils.FirstNonEmpty(src.PixelFormat, "Unknown"), utils.FirstNonEmpty(src.PixelFormat, "Unknown"),
utils.FirstNonEmpty(src.FieldOrder, "Unknown"), interlacing,
colorSpace,
colorRange,
gopSize,
utils.FirstNonEmpty(src.AudioCodec, "Unknown"), utils.FirstNonEmpty(src.AudioCodec, "Unknown"),
audioBitrate,
src.AudioRate, src.AudioRate,
utils.ChannelLabel(src.Channels), utils.ChannelLabel(src.Channels),
chapters,
metadata,
) )
info := widget.NewForm( info := widget.NewForm(
@ -2298,15 +2392,22 @@ Channels: %s`,
widget.NewFormItem("Format", widget.NewLabel(utils.FirstNonEmpty(src.Format, "Unknown"))), widget.NewFormItem("Format", widget.NewLabel(utils.FirstNonEmpty(src.Format, "Unknown"))),
widget.NewFormItem("Resolution", widget.NewLabel(fmt.Sprintf("%dx%d", src.Width, src.Height))), widget.NewFormItem("Resolution", widget.NewLabel(fmt.Sprintf("%dx%d", src.Width, src.Height))),
widget.NewFormItem("Aspect Ratio", widget.NewLabel(src.AspectRatioString())), widget.NewFormItem("Aspect Ratio", widget.NewLabel(src.AspectRatioString())),
widget.NewFormItem("Pixel Aspect Ratio", widget.NewLabel(par)),
widget.NewFormItem("Duration", widget.NewLabel(src.DurationString())), widget.NewFormItem("Duration", widget.NewLabel(src.DurationString())),
widget.NewFormItem("Video Codec", widget.NewLabel(utils.FirstNonEmpty(src.VideoCodec, "Unknown"))), widget.NewFormItem("Video Codec", widget.NewLabel(utils.FirstNonEmpty(src.VideoCodec, "Unknown"))),
widget.NewFormItem("Video Bitrate", widget.NewLabel(bitrate)), widget.NewFormItem("Video Bitrate", widget.NewLabel(bitrate)),
widget.NewFormItem("Frame Rate", widget.NewLabel(fmt.Sprintf("%.2f fps", src.FrameRate))), widget.NewFormItem("Frame Rate", widget.NewLabel(fmt.Sprintf("%.2f fps", src.FrameRate))),
widget.NewFormItem("Pixel Format", widget.NewLabel(utils.FirstNonEmpty(src.PixelFormat, "Unknown"))), widget.NewFormItem("Pixel Format", widget.NewLabel(utils.FirstNonEmpty(src.PixelFormat, "Unknown"))),
widget.NewFormItem("Field Order", widget.NewLabel(utils.FirstNonEmpty(src.FieldOrder, "Unknown"))), widget.NewFormItem("Interlacing", widget.NewLabel(interlacing)),
widget.NewFormItem("Color Space", widget.NewLabel(colorSpace)),
widget.NewFormItem("Color Range", widget.NewLabel(colorRange)),
widget.NewFormItem("GOP Size", widget.NewLabel(gopSize)),
widget.NewFormItem("Audio Codec", widget.NewLabel(utils.FirstNonEmpty(src.AudioCodec, "Unknown"))), widget.NewFormItem("Audio Codec", widget.NewLabel(utils.FirstNonEmpty(src.AudioCodec, "Unknown"))),
widget.NewFormItem("Audio Bitrate", widget.NewLabel(audioBitrate)),
widget.NewFormItem("Audio Rate", widget.NewLabel(fmt.Sprintf("%d Hz", src.AudioRate))), widget.NewFormItem("Audio Rate", widget.NewLabel(fmt.Sprintf("%d Hz", src.AudioRate))),
widget.NewFormItem("Channels", widget.NewLabel(utils.ChannelLabel(src.Channels))), widget.NewFormItem("Channels", widget.NewLabel(utils.ChannelLabel(src.Channels))),
widget.NewFormItem("Chapters", widget.NewLabel(chapters)),
widget.NewFormItem("Metadata", widget.NewLabel(metadata)),
) )
for _, item := range info.Items { for _, item := range info.Items {
if lbl, ok := item.Widget.(*widget.Label); ok { if lbl, ok := item.Widget.(*widget.Label); ok {
@ -3583,10 +3684,8 @@ func determineVideoCodec(cfg convertConfig) string {
return "h264_qsv" return "h264_qsv"
} else if cfg.HardwareAccel == "videotoolbox" { } else if cfg.HardwareAccel == "videotoolbox" {
return "h264_videotoolbox" return "h264_videotoolbox"
} else if cfg.HardwareAccel == "none" || cfg.HardwareAccel == "" {
// Auto-detect best available encoder
return detectBestH264Encoder()
} }
// When set to "none" or empty, use software encoder
return "libx264" return "libx264"
case "H.265": case "H.265":
if cfg.HardwareAccel == "nvenc" { if cfg.HardwareAccel == "nvenc" {
@ -3595,10 +3694,8 @@ func determineVideoCodec(cfg convertConfig) string {
return "hevc_qsv" return "hevc_qsv"
} else if cfg.HardwareAccel == "videotoolbox" { } else if cfg.HardwareAccel == "videotoolbox" {
return "hevc_videotoolbox" return "hevc_videotoolbox"
} else if cfg.HardwareAccel == "none" || cfg.HardwareAccel == "" {
// Auto-detect best available encoder
return detectBestH265Encoder()
} }
// When set to "none" or empty, use software encoder
return "libx265" return "libx265"
case "VP9": case "VP9":
return "libvpx-vp9" return "libvpx-vp9"
@ -3711,11 +3808,13 @@ func (s *appState) startConvert(status *widget.Label, btn, cancelBtn *widget.But
args = append(args, "-i", cfg.CoverArtPath) args = append(args, "-i", cfg.CoverArtPath)
} }
// Hardware acceleration // Hardware acceleration for decoding
// Note: NVENC doesn't need -hwaccel for encoding, only for decoding
if cfg.HardwareAccel != "none" && cfg.HardwareAccel != "" { if cfg.HardwareAccel != "none" && cfg.HardwareAccel != "" {
switch cfg.HardwareAccel { switch cfg.HardwareAccel {
case "nvenc": case "nvenc":
args = append(args, "-hwaccel", "cuda") // For NVENC, we don't add -hwaccel flags
// The h264_nvenc/hevc_nvenc encoder handles GPU encoding directly
case "vaapi": case "vaapi":
args = append(args, "-hwaccel", "vaapi") args = append(args, "-hwaccel", "vaapi")
case "qsv": case "qsv":
@ -4053,9 +4152,36 @@ func (s *appState) startConvert(status *widget.Label, btn, cancelBtn *widget.But
s.convertCancel = nil s.convertCancel = nil
return return
} }
logging.Debug(logging.CatFFMPEG, "convert failed: %v stderr=%s", err, strings.TrimSpace(stderr.String())) stderrOutput := strings.TrimSpace(stderr.String())
logging.Debug(logging.CatFFMPEG, "convert failed: %v stderr=%s", err, stderrOutput)
fyne.CurrentApp().Driver().DoFromGoroutine(func() { fyne.CurrentApp().Driver().DoFromGoroutine(func() {
s.showErrorWithCopy("Conversion Failed", fmt.Errorf("convert failed: %w", err)) errorExplanation := interpretFFmpegError(err)
var errorMsg error
// Check if this is a hardware encoding failure
isHardwareFailure := strings.Contains(stderrOutput, "No capable devices found") ||
strings.Contains(stderrOutput, "Cannot load") ||
strings.Contains(stderrOutput, "not available") &&
(strings.Contains(stderrOutput, "nvenc") ||
strings.Contains(stderrOutput, "qsv") ||
strings.Contains(stderrOutput, "vaapi") ||
strings.Contains(stderrOutput, "videotoolbox"))
if isHardwareFailure && s.convert.HardwareAccel != "none" && s.convert.HardwareAccel != "" {
errorMsg = fmt.Errorf("Hardware encoding (%s) failed - no compatible hardware found.\n\nPlease disable hardware acceleration in the conversion settings and try again with software encoding.\n\nFFmpeg output:\n%s", s.convert.HardwareAccel, stderrOutput)
} else {
baseMsg := "convert failed: " + err.Error()
if errorExplanation != "" {
baseMsg = fmt.Sprintf("convert failed: %v - %s", err, errorExplanation)
}
if stderrOutput != "" {
errorMsg = fmt.Errorf("%s\n\nFFmpeg output:\n%s", baseMsg, stderrOutput)
} else {
errorMsg = fmt.Errorf("%s", baseMsg)
}
}
s.showErrorWithCopy("Conversion Failed", errorMsg)
s.convertBusy = false s.convertBusy = false
s.convertActiveIn = "" s.convertActiveIn = ""
s.convertActiveOut = "" s.convertActiveOut = ""
@ -4115,6 +4241,49 @@ func etaOrDash(s string) string {
return s return s
} }
// interpretFFmpegError adds a human-readable explanation for common FFmpeg error codes
func interpretFFmpegError(err error) string {
if err == nil {
return ""
}
// Extract exit code from error
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
exitCode := exitErr.ExitCode()
// Common FFmpeg/OS error codes and their meanings
switch exitCode {
case 1:
return "Generic error (check FFmpeg output for details)"
case 2:
return "Invalid command line arguments"
case 126:
return "Command cannot execute (permission denied)"
case 127:
return "Command not found (is FFmpeg installed?)"
case 137:
return "Process killed (out of memory?)"
case 139:
return "Segmentation fault (FFmpeg crashed)"
case 143:
return "Process terminated by signal (SIGTERM)"
case 187:
return "Protocol/format not found or filter syntax error (check input file format and filter settings)"
case 255:
return "FFmpeg error (check output for details)"
default:
if exitCode > 128 && exitCode < 160 {
signal := exitCode - 128
return fmt.Sprintf("Process terminated by signal %d", signal)
}
return fmt.Sprintf("Exit code %d", exitCode)
}
}
return ""
}
func aspectFilters(target float64, mode string) []string { func aspectFilters(target float64, mode string) []string {
if target <= 0 { if target <= 0 {
return nil return nil
@ -4384,7 +4553,8 @@ type videoSource struct {
Duration float64 Duration float64
VideoCodec string VideoCodec string
AudioCodec string AudioCodec string
Bitrate int Bitrate int // Video bitrate in bits per second
AudioBitrate int // Audio bitrate in bits per second
FrameRate float64 FrameRate float64
PixelFormat string PixelFormat string
AudioRate int AudioRate int
@ -4392,6 +4562,14 @@ type videoSource struct {
FieldOrder string FieldOrder string
PreviewFrames []string PreviewFrames []string
EmbeddedCoverArt string // Path to extracted embedded cover art, if any EmbeddedCoverArt string // Path to extracted embedded cover art, if any
// Advanced metadata
SampleAspectRatio string // Pixel Aspect Ratio (SAR) - e.g., "1:1", "40:33"
ColorSpace string // Color space/primaries - e.g., "bt709", "bt601"
ColorRange string // Color range - "tv" (limited) or "pc" (full)
GOPSize int // GOP size / keyframe interval
HasChapters bool // Whether file has embedded chapters
HasMetadata bool // Whether file has title/copyright/etc metadata
} }
func (v *videoSource) DurationString() string { func (v *videoSource) DurationString() string {