Fix CRF UI sync and stabilize player

This commit is contained in:
Stu Leak 2026-01-04 16:45:08 -05:00
parent 6ad6e8ef54
commit 57f2076f9f
3 changed files with 76 additions and 52 deletions

View File

@ -12,20 +12,20 @@ import (
"sync" "sync"
"time" "time"
"git.leaktechnologies.dev/stu/VideoTools/internal/utils"
"git.leaktechnologies.dev/stu/VideoTools/internal/logging" "git.leaktechnologies.dev/stu/VideoTools/internal/logging"
"git.leaktechnologies.dev/stu/VideoTools/internal/utils"
) )
// UnifiedPlayer implements rock-solid video playback with proper A/V synchronization // UnifiedPlayer implements rock-solid video playback with proper A/V synchronization
// and frame-accurate seeking using a single FFmpeg process // and frame-accurate seeking using a single FFmpeg process
type UnifiedPlayer struct { type UnifiedPlayer struct {
mu sync.RWMutex mu sync.RWMutex
ctx context.Context ctx context.Context
cancel context.CancelFunc cancel context.CancelFunc
// FFmpeg process // FFmpeg process
cmd *exec.Cmd cmd *exec.Cmd
stdin *bufio.Writer stdin *bufio.Writer
stdout *bufio.Reader stdout *bufio.Reader
stderr *bufio.Reader stderr *bufio.Reader
@ -36,30 +36,30 @@ type UnifiedPlayer struct {
audioPipeWriter *io.PipeWriter audioPipeWriter *io.PipeWriter
// State tracking // State tracking
currentPath string currentPath string
currentTime time.Duration currentTime time.Duration
currentFrame int64 currentFrame int64
duration time.Duration duration time.Duration
frameRate float64 frameRate float64
state PlayerState state PlayerState
volume float64 volume float64
speed float64 speed float64
muted bool muted bool
fullscreen bool fullscreen bool
previewMode bool previewMode bool
// Video info // Video info
videoInfo *VideoInfo videoInfo *VideoInfo
// Synchronization // Synchronization
syncClock time.Time syncClock time.Time
videoPTS int64 videoPTS int64
audioPTS int64 audioPTS int64
ptsOffset int64 ptsOffset int64
// Buffer management // Buffer management
frameBuffer *sync.Pool frameBuffer *sync.Pool
audioBuffer []byte audioBuffer []byte
audioBufferSize int audioBufferSize int
// Window state // Window state
@ -67,7 +67,7 @@ type UnifiedPlayer struct {
windowW, windowH int windowW, windowH int
// Callbacks // Callbacks
timeCallback func(time.Duration) timeCallback func(time.Duration)
frameCallback func(int64) frameCallback func(int64)
stateCallback func(PlayerState) stateCallback func(PlayerState)
@ -80,14 +80,14 @@ func NewUnifiedPlayer(config Config) *UnifiedPlayer {
player := &UnifiedPlayer{ player := &UnifiedPlayer{
config: config, config: config,
frameBuffer: &sync.Pool{ frameBuffer: &sync.Pool{
New: func() interface{} { New: func() interface{} {
return &image.RGBA{ return &image.RGBA{
Pix: make([]uint8, 0), Pix: make([]uint8, 0),
Stride: 0, Stride: 0,
Rect: image.Rect(0, 0, 0, 0), Rect: image.Rect(0, 0, 0, 0),
} }
},
}, },
},
audioBufferSize: 32768, // 170ms at 48kHz for smooth playback audioBufferSize: 32768, // 170ms at 48kHz for smooth playback
} }
@ -555,26 +555,25 @@ func (p *UnifiedPlayer) detectVideoProperties() error {
} }
} }
if p.frameRate > 0 && p.duration > 0 { if p.frameRate > 0 && p.duration > 0 {
p.videoInfo = &VideoInfo{ p.videoInfo = &VideoInfo{
Width: p.windowW, Width: p.windowW,
Height: p.windowH, Height: p.windowH,
Duration: p.duration, Duration: p.duration,
FrameRate: p.frameRate, FrameRate: p.frameRate,
FrameCount: int64(p.duration.Seconds() * p.frameRate), FrameCount: int64(p.duration.Seconds() * p.frameRate),
} }
} else { } else {
p.videoInfo = &VideoInfo{ p.videoInfo = &VideoInfo{
Width: p.windowW, Width: p.windowW,
Height: p.windowH, Height: p.windowH,
Duration: p.duration, Duration: p.duration,
FrameRate: p.frameRate, FrameRate: p.frameRate,
FrameCount: 0, FrameCount: 0,
} }
} }
logging.Debug(logging.CatPlayer, "Video properties: %dx%d@%.3ffps, %.2fs", logging.Debug(logging.CatPlayer, "Video properties: %dx%d@%.3ffps, %.2fs",
p.windowW, p.windowH, p.frameRate, p.duration.Seconds()) p.windowW, p.windowH, p.frameRate, p.duration.Seconds())
return nil return nil
@ -589,13 +588,18 @@ func (p *UnifiedPlayer) writeStringToStdin(cmd string) {
// updateAVSync maintains synchronization between audio and video // updateAVSync maintains synchronization between audio and video
func (p *UnifiedPlayer) updateAVSync() { func (p *UnifiedPlayer) updateAVSync() {
// Simple drift correction using master clock reference // PTS-based drift correction with adaptive timing
p.mu.RLock()
defer p.mu.RUnlock()
if p.audioPTS > 0 && p.videoPTS > 0 { if p.audioPTS > 0 && p.videoPTS > 0 {
drift := p.audioPTS - p.videoPTS drift := p.audioPTS - p.videoPTS
if abs(drift) > 1000 { // More than 1 frame of drift if abs(drift) > 900 { // More than 10ms of drift (at 90kHz)
logging.Debug(logging.CatPlayer, "A/V sync drift: %d PTS", drift)
// Gradual adjustment to avoid audio glitches
p.ptsOffset += drift / 10 // 10% correction per frame
} else {
logging.Debug(logging.CatPlayer, "A/V sync drift: %d PTS", drift) logging.Debug(logging.CatPlayer, "A/V sync drift: %d PTS", drift)
// Adjust sync clock gradually
p.ptsOffset += drift / 100
} }
} }
} }
@ -604,7 +608,7 @@ func (p *UnifiedPlayer) updateAVSync() {
func (p *UnifiedPlayer) addHardwareAcceleration(args []string) []string { func (p *UnifiedPlayer) addHardwareAcceleration(args []string) []string {
// This is a placeholder - actual implementation would detect available hardware // This is a placeholder - actual implementation would detect available hardware
// and add appropriate flags like "-hwaccel cuda", "-c:v h264_nvenc" // and add appropriate flags like "-hwaccel cuda", "-c:v h264_nvenc"
// For now, just log that hardware acceleration is considered // For now, just log that hardware acceleration is considered
logging.Debug(logging.CatPlayer, "Hardware acceleration requested but not yet implemented") logging.Debug(logging.CatPlayer, "Hardware acceleration requested but not yet implemented")
return args return args
@ -624,14 +628,14 @@ func (p *UnifiedPlayer) applyVolumeToBuffer(buffer []byte) {
if i+1 < len(buffer) { if i+1 < len(buffer) {
sample := int16(binary.LittleEndian.Uint16(buffer[i : i+2])) sample := int16(binary.LittleEndian.Uint16(buffer[i : i+2]))
adjusted := int(float64(sample) * gain) adjusted := int(float64(sample) * gain)
// Clamp to int16 range // Clamp to int16 range
if adjusted > 32767 { if adjusted > 32767 {
adjusted = 32767 adjusted = 32767
} else if adjusted < -32768 { } else if adjusted < -32768 {
adjusted = -32768 adjusted = -32768
} }
binary.LittleEndian.PutUint16(buffer[i:i+2], uint16(adjusted)) binary.LittleEndian.PutUint16(buffer[i:i+2], uint16(adjusted))
} }
} }

View File

@ -44,7 +44,7 @@ var (
// Audio Codec Colors (Secondary but Distinct) // Audio Codec Colors (Secondary but Distinct)
var ( var (
ColorOpus = utils.MustHex("#8B5CF6") // Violet - Modern audio ColorOpus = utils.MustHex("#8B5CF6") // Violet - Modern audio
ColorAAC = utils.MustHex("#7C3AED") // Purple-Blue - Common audio ColorAAC = utils.MustHex("#06B6D4") // Cyan - Common audio (distinct from purple codecs)
ColorFLAC = utils.MustHex("#EC4899") // Magenta - Lossless audio ColorFLAC = utils.MustHex("#EC4899") // Magenta - Lossless audio
ColorMP3 = utils.MustHex("#F43F5E") // Rose - Legacy audio ColorMP3 = utils.MustHex("#F43F5E") // Rose - Legacy audio
ColorAC3 = utils.MustHex("#F97316") // Orange-Red - Surround audio ColorAC3 = utils.MustHex("#F97316") // Orange-Red - Surround audio

34
main.go
View File

@ -6883,6 +6883,22 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
manualQualityOption := "Manual (CRF)" manualQualityOption := "Manual (CRF)"
var crfEntry *widget.Entry var crfEntry *widget.Entry
var manualCrfRow *fyne.Container var manualCrfRow *fyne.Container
var crfContainer *fyne.Container
normalizeBitrateMode := func(mode string) string {
switch {
case strings.HasPrefix(mode, "CRF"):
return "CRF"
case strings.HasPrefix(mode, "CBR"):
return "CBR"
case strings.HasPrefix(mode, "VBR"):
return "VBR"
case strings.HasPrefix(mode, "Target Size"):
return "Target Size"
default:
return mode
}
}
// State setters with automatic widget synchronization // State setters with automatic widget synchronization
setQuality := func(val string) { setQuality := func(val string) {
@ -6898,6 +6914,17 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
crfEntry.SetText("23") crfEntry.SetText("23")
} }
} }
if normalizeBitrateMode(state.convert.BitrateMode) == "CRF" {
if manualCrfRow != nil {
manualCrfRow.Show()
}
if crfEntry != nil {
crfEntry.Enable()
}
if crfContainer != nil {
crfContainer.Show()
}
}
} else { } else {
if state.convert.CRF != "" { if state.convert.CRF != "" {
state.convert.CRF = "" state.convert.CRF = ""
@ -7214,7 +7241,6 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
qualitySectionSimple fyne.CanvasObject qualitySectionSimple fyne.CanvasObject
qualitySectionAdv fyne.CanvasObject qualitySectionAdv fyne.CanvasObject
simpleBitrateSelect *widget.Select simpleBitrateSelect *widget.Select
crfContainer *fyne.Container
bitrateContainer *fyne.Container bitrateContainer *fyne.Container
targetSizeContainer *fyne.Container targetSizeContainer *fyne.Container
resetConvertDefaults func() resetConvertDefaults func()
@ -7913,12 +7939,6 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
"VBR": "VBR (Variable Bitrate)", "VBR": "VBR (Variable Bitrate)",
"Target Size": "Target Size (Calculate from file size)", "Target Size": "Target Size (Calculate from file size)",
} }
normalizeBitrateMode := func(mode string) string {
if shortCode, ok := bitrateModeMap[mode]; ok {
return shortCode
}
return mode
}
bitrateModeSelect = widget.NewSelect(bitrateModeOptions, func(value string) { bitrateModeSelect = widget.NewSelect(bitrateModeOptions, func(value string) {
// Extract short code from label // Extract short code from label
if shortCode, ok := bitrateModeMap[value]; ok { if shortCode, ok := bitrateModeMap[value]; ok {