forked from Leak_Technologies/VideoTools
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"
|
||||||
"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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
218
main.go
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user