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:
parent
292da5c59e
commit
6a2f1fff3f
Binary file not shown.
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 45 KiB |
|
|
@ -7,6 +7,7 @@ import (
|
|||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.leaktechnologies.dev/stu/VideoTools/internal/logging"
|
||||
|
|
@ -96,26 +97,34 @@ func ProbeVideo(path string) (*VideoSource, error) {
|
|||
|
||||
var result struct {
|
||||
Format struct {
|
||||
Filename string `json:"filename"`
|
||||
Format string `json:"format_long_name"`
|
||||
Duration string `json:"duration"`
|
||||
FormatName string `json:"format_name"`
|
||||
BitRate string `json:"bit_rate"`
|
||||
Filename string `json:"filename"`
|
||||
Format string `json:"format_long_name"`
|
||||
Duration string `json:"duration"`
|
||||
FormatName string `json:"format_name"`
|
||||
BitRate string `json:"bit_rate"`
|
||||
Tags map[string]interface{} `json:"tags"`
|
||||
} `json:"format"`
|
||||
Streams []struct {
|
||||
Index int `json:"index"`
|
||||
CodecType string `json:"codec_type"`
|
||||
CodecName string `json:"codec_name"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Duration string `json:"duration"`
|
||||
BitRate string `json:"bit_rate"`
|
||||
PixFmt string `json:"pix_fmt"`
|
||||
SampleRate string `json:"sample_rate"`
|
||||
Channels int `json:"channels"`
|
||||
AvgFrameRate string `json:"avg_frame_rate"`
|
||||
FieldOrder string `json:"field_order"`
|
||||
Disposition struct {
|
||||
Chapters []interface{} `json:"chapters"`
|
||||
Streams []struct {
|
||||
Index int `json:"index"`
|
||||
CodecType string `json:"codec_type"`
|
||||
CodecName string `json:"codec_name"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Duration string `json:"duration"`
|
||||
BitRate string `json:"bit_rate"`
|
||||
PixFmt string `json:"pix_fmt"`
|
||||
SampleRate string `json:"sample_rate"`
|
||||
Channels int `json:"channels"`
|
||||
AvgFrameRate string `json:"avg_frame_rate"`
|
||||
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"`
|
||||
} `json:"disposition"`
|
||||
} `json:"streams"`
|
||||
|
|
@ -137,6 +146,22 @@ func ProbeVideo(path string) (*VideoSource, error) {
|
|||
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)
|
||||
foundMainVideo := false
|
||||
var coverArtStreamIndex int = -1
|
||||
|
|
@ -170,6 +195,23 @@ func ProbeVideo(path string) (*VideoSource, error) {
|
|||
if 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 br, err := utils.ParseInt(stream.BitRate); err == nil {
|
||||
|
|
@ -185,6 +227,9 @@ 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,8 +28,9 @@ type ConvertConfig struct {
|
|||
VideoCodec string // H.264, H.265, VP9, AV1, Copy
|
||||
EncoderPreset string // ultrafast, superfast, veryfast, faster, fast, medium, slow, slower, veryslow
|
||||
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")
|
||||
TargetFileSize string // Target file size (e.g., "25MB", "100MB", "8MB") - requires BitrateMode=TargetSize
|
||||
TargetResolution string // Source, 720p, 1080p, 1440p, 4K, or custom
|
||||
FrameRate string // Source, 24, 30, 60, or custom
|
||||
PixelFormat string // yuv420p, yuv422p, yuv444p
|
||||
|
|
@ -76,7 +77,8 @@ type VideoSource struct {
|
|||
Duration float64
|
||||
VideoCodec string
|
||||
AudioCodec string
|
||||
Bitrate int
|
||||
Bitrate int // Video bitrate in bits per second
|
||||
AudioBitrate int // Audio bitrate in bits per second
|
||||
FrameRate float64
|
||||
PixelFormat string
|
||||
AudioRate int
|
||||
|
|
@ -84,6 +86,14 @@ type VideoSource struct {
|
|||
FieldOrder string
|
||||
PreviewFrames []string
|
||||
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)
|
||||
|
|
@ -155,6 +165,76 @@ func ResolveTargetAspect(val string, src *VideoSource) float64 {
|
|||
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
|
||||
func AspectFilters(target float64, mode string) []string {
|
||||
if target <= 0 {
|
||||
|
|
|
|||
218
main.go
218
main.go
|
|
@ -117,8 +117,9 @@ type convertConfig struct {
|
|||
VideoCodec string // H.264, H.265, VP9, AV1, Copy
|
||||
EncoderPreset string // ultrafast, superfast, veryfast, faster, fast, medium, slow, slower, veryslow
|
||||
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")
|
||||
TargetFileSize string // Target file size (e.g., "25MB", "100MB") - requires BitrateMode=TargetSize
|
||||
TargetResolution string // Source, 720p, 1080p, 1440p, 4K, or custom
|
||||
FrameRate string // Source, 24, 30, 60, or custom
|
||||
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)
|
||||
}
|
||||
|
||||
// Hardware acceleration
|
||||
// Hardware acceleration for decoding
|
||||
// Note: NVENC doesn't need -hwaccel for encoding, only for decoding
|
||||
hardwareAccel, _ := cfg["hardwareAccel"].(string)
|
||||
if hardwareAccel != "none" && hardwareAccel != "" {
|
||||
switch hardwareAccel {
|
||||
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":
|
||||
args = append(args, "-hwaccel", "vaapi")
|
||||
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, " "))
|
||||
|
||||
// Also print to stdout for debugging
|
||||
fmt.Printf("\n=== FFMPEG COMMAND ===\nffmpeg %s\n======================\n\n", strings.Join(args, " "))
|
||||
|
||||
// Execute FFmpeg
|
||||
cmd := exec.CommandContext(ctx, "ffmpeg", args...)
|
||||
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)
|
||||
}
|
||||
|
||||
// Capture stderr for error messages
|
||||
var stderrBuf strings.Builder
|
||||
cmd.Stderr = &stderrBuf
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
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 {
|
||||
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)
|
||||
|
|
@ -1362,7 +1401,7 @@ func runGUI() {
|
|||
VideoBitrate: "5000k",
|
||||
TargetResolution: "Source",
|
||||
FrameRate: "Source",
|
||||
PixelFormat: "yuv420p10le",
|
||||
PixelFormat: "yuv420p",
|
||||
HardwareAccel: "none",
|
||||
TwoPass: false,
|
||||
H264Profile: "main",
|
||||
|
|
@ -2264,33 +2303,88 @@ func buildMetadataPanel(state *appState, src *videoSource, min fyne.Size) (fyne.
|
|||
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
|
||||
metadataText := fmt.Sprintf(`File: %s
|
||||
Format: %s
|
||||
Resolution: %dx%d
|
||||
Aspect Ratio: %s
|
||||
Pixel Aspect Ratio: %s
|
||||
Duration: %s
|
||||
Video Codec: %s
|
||||
Video Bitrate: %s
|
||||
Frame Rate: %.2f fps
|
||||
Pixel Format: %s
|
||||
Field Order: %s
|
||||
Interlacing: %s
|
||||
Color Space: %s
|
||||
Color Range: %s
|
||||
GOP Size: %s
|
||||
Audio Codec: %s
|
||||
Audio Bitrate: %s
|
||||
Audio Rate: %d Hz
|
||||
Channels: %s`,
|
||||
Channels: %s
|
||||
Chapters: %s
|
||||
Metadata: %s`,
|
||||
src.DisplayName,
|
||||
utils.FirstNonEmpty(src.Format, "Unknown"),
|
||||
src.Width, src.Height,
|
||||
src.AspectRatioString(),
|
||||
par,
|
||||
src.DurationString(),
|
||||
utils.FirstNonEmpty(src.VideoCodec, "Unknown"),
|
||||
bitrate,
|
||||
src.FrameRate,
|
||||
utils.FirstNonEmpty(src.PixelFormat, "Unknown"),
|
||||
utils.FirstNonEmpty(src.FieldOrder, "Unknown"),
|
||||
interlacing,
|
||||
colorSpace,
|
||||
colorRange,
|
||||
gopSize,
|
||||
utils.FirstNonEmpty(src.AudioCodec, "Unknown"),
|
||||
audioBitrate,
|
||||
src.AudioRate,
|
||||
utils.ChannelLabel(src.Channels),
|
||||
chapters,
|
||||
metadata,
|
||||
)
|
||||
|
||||
info := widget.NewForm(
|
||||
|
|
@ -2298,15 +2392,22 @@ Channels: %s`,
|
|||
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("Aspect Ratio", widget.NewLabel(src.AspectRatioString())),
|
||||
widget.NewFormItem("Pixel Aspect Ratio", widget.NewLabel(par)),
|
||||
widget.NewFormItem("Duration", widget.NewLabel(src.DurationString())),
|
||||
widget.NewFormItem("Video Codec", widget.NewLabel(utils.FirstNonEmpty(src.VideoCodec, "Unknown"))),
|
||||
widget.NewFormItem("Video Bitrate", widget.NewLabel(bitrate)),
|
||||
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("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 Bitrate", widget.NewLabel(audioBitrate)),
|
||||
widget.NewFormItem("Audio Rate", widget.NewLabel(fmt.Sprintf("%d Hz", src.AudioRate))),
|
||||
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 {
|
||||
if lbl, ok := item.Widget.(*widget.Label); ok {
|
||||
|
|
@ -3583,10 +3684,8 @@ func determineVideoCodec(cfg convertConfig) string {
|
|||
return "h264_qsv"
|
||||
} else if cfg.HardwareAccel == "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"
|
||||
case "H.265":
|
||||
if cfg.HardwareAccel == "nvenc" {
|
||||
|
|
@ -3595,10 +3694,8 @@ func determineVideoCodec(cfg convertConfig) string {
|
|||
return "hevc_qsv"
|
||||
} else if cfg.HardwareAccel == "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"
|
||||
case "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)
|
||||
}
|
||||
|
||||
// Hardware acceleration
|
||||
// Hardware acceleration for decoding
|
||||
// Note: NVENC doesn't need -hwaccel for encoding, only for decoding
|
||||
if cfg.HardwareAccel != "none" && cfg.HardwareAccel != "" {
|
||||
switch cfg.HardwareAccel {
|
||||
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":
|
||||
args = append(args, "-hwaccel", "vaapi")
|
||||
case "qsv":
|
||||
|
|
@ -4053,9 +4152,36 @@ func (s *appState) startConvert(status *widget.Label, btn, cancelBtn *widget.But
|
|||
s.convertCancel = nil
|
||||
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() {
|
||||
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.convertActiveIn = ""
|
||||
s.convertActiveOut = ""
|
||||
|
|
@ -4115,6 +4241,49 @@ func etaOrDash(s string) string {
|
|||
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 {
|
||||
if target <= 0 {
|
||||
return nil
|
||||
|
|
@ -4384,7 +4553,8 @@ type videoSource struct {
|
|||
Duration float64
|
||||
VideoCodec string
|
||||
AudioCodec string
|
||||
Bitrate int
|
||||
Bitrate int // Video bitrate in bits per second
|
||||
AudioBitrate int // Audio bitrate in bits per second
|
||||
FrameRate float64
|
||||
PixelFormat string
|
||||
AudioRate int
|
||||
|
|
@ -4392,6 +4562,14 @@ type videoSource struct {
|
|||
FieldOrder string
|
||||
PreviewFrames []string
|
||||
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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user