Replace internal decoder with ffplay subprocess

This commit is contained in:
Stu 2025-12-10 05:27:24 -05:00
parent 3d43123840
commit 4929918d4b

422
main.go
View File

@ -3704,24 +3704,16 @@ func buildVideoPane(state *appState, min fyne.Size, src *videoSource, onCover fu
} }
type playSession struct { type playSession struct {
path string path string
fps float64 fps float64
width int volume float64
height int muted bool
targetW int paused bool
targetH int current float64
volume float64 mu sync.Mutex
muted bool cmd *exec.Cmd
paused bool stdin io.WriteCloser
current float64 done chan struct{}
stop chan struct{}
done chan struct{}
prog func(float64)
img *canvas.Image
mu sync.Mutex
videoCmd *exec.Cmd
audioCmd *exec.Cmd
frameN int
} }
var audioCtxGlobal struct { var audioCtxGlobal struct {
@ -3743,42 +3735,26 @@ func newPlaySession(path string, w, h int, fps float64, targetW, targetH int, pr
fmt.Printf("═══════════════════════════════════════════════════════\n") fmt.Printf("═══════════════════════════════════════════════════════\n")
fmt.Printf("📁 Video: %s\n", filepath.Base(path)) fmt.Printf("📁 Video: %s\n", filepath.Base(path))
fmt.Printf("📐 Source: %dx%d @ %.2f fps\n", w, h, fps) fmt.Printf("📐 Source: %dx%d @ %.2f fps\n", w, h, fps)
fmt.Printf("🎯 Target: %dx%d\n", targetW, targetH) fmt.Printf("🎯 Playback via ffplay subprocess\n")
fmt.Printf("═══════════════════════════════════════════════════════\n\n") fmt.Printf("═══════════════════════════════════════════════════════\n\n")
// Validate input parameters
if fps <= 0 {
fps = 24
fmt.Printf("⚠️ Invalid FPS (%.2f), defaulting to 24\n", fps)
}
if targetW <= 0 {
targetW = 640
fmt.Printf("⚠️ Invalid target width (%d), defaulting to 640\n", targetW)
}
if targetH <= 0 {
targetH = int(float64(targetW) * (float64(h) / float64(utils.MaxInt(w, 1))))
fmt.Printf("⚠️ Invalid target height (%d), calculating to %d\n", targetH, targetH)
}
// Check if video file exists // Check if video file exists
if _, err := os.Stat(path); os.IsNotExist(err) { if _, err := os.Stat(path); os.IsNotExist(err) {
fmt.Printf("❌ ERROR: Video file does not exist: %s\n", path) fmt.Printf("❌ ERROR: Video file does not exist: %s\n", path)
return nil return nil
} }
if _, err := exec.LookPath("ffplay"); err != nil {
fmt.Printf("❌ ERROR: ffplay not found in PATH: %v\n", err)
return nil
}
fmt.Printf("✅ Play session created successfully\n") fmt.Printf("✅ Play session created successfully\n")
return &playSession{ return &playSession{
path: path, path: path,
fps: fps, fps: fps,
width: w, volume: 100,
height: h, done: make(chan struct{}),
targetW: targetW,
targetH: targetH,
volume: 100,
stop: make(chan struct{}),
done: make(chan struct{}),
prog: prog,
img: img,
} }
} }
@ -3786,19 +3762,27 @@ func (p *playSession) Play() {
fmt.Printf("▶️ PLAY called (current position: %.2fs)\n", p.current) fmt.Printf("▶️ PLAY called (current position: %.2fs)\n", p.current)
p.mu.Lock() p.mu.Lock()
defer p.mu.Unlock() defer p.mu.Unlock()
if p.videoCmd == nil && p.audioCmd == nil { if p.cmd == nil {
fmt.Printf("⚡ Starting playback from scratch...\n") fmt.Printf("⚡ Starting ffplay subprocess...\n")
p.startLocked(p.current) p.startFFplayLocked(p.current)
return return
} }
fmt.Printf("▶️ Resuming playback\n") // Toggle pause/resume by sending "p" to stdin
p.paused = false if p.stdin != nil {
_, _ = p.stdin.Write([]byte("p"))
p.paused = !p.paused
fmt.Printf("⏯️ Toggled pause -> %v\n", p.paused)
}
} }
func (p *playSession) Pause() { func (p *playSession) Pause() {
p.mu.Lock() p.mu.Lock()
defer p.mu.Unlock() defer p.mu.Unlock()
p.paused = true if p.stdin != nil {
_, _ = p.stdin.Write([]byte("p"))
p.paused = !p.paused
fmt.Printf("⏸️ Pause toggle -> %v\n", p.paused)
}
} }
func (p *playSession) Seek(offset float64) { func (p *playSession) Seek(offset float64) {
@ -3807,22 +3791,9 @@ func (p *playSession) Seek(offset float64) {
if offset < 0 { if offset < 0 {
offset = 0 offset = 0
} }
paused := p.paused
p.current = offset p.current = offset
p.stopLocked() p.stopLocked()
p.startLocked(p.current) p.startFFplayLocked(p.current)
p.paused = paused
if p.paused {
// Ensure loops honor paused right after restart.
time.AfterFunc(30*time.Millisecond, func() {
p.mu.Lock()
defer p.mu.Unlock()
p.paused = true
})
}
if p.prog != nil {
p.prog(p.current)
}
} }
func (p *playSession) SetVolume(v float64) { func (p *playSession) SetVolume(v float64) {
@ -3840,41 +3811,15 @@ func (p *playSession) SetVolume(v float64) {
} else { } else {
p.muted = true p.muted = true
} }
// Runtime volume changes are not pushed to ffplay; a restart or pause toggle will pick it up.
} }
// StepFrame steps forward or backward by one frame // StepFrame steps forward or backward by one frame
// direction: 1 for forward, -1 for backward // direction: 1 for forward, -1 for backward
func (p *playSession) StepFrame(direction int) { func (p *playSession) StepFrame(direction int) {
p.mu.Lock() // Approximate by seeking one frame forward/backward
defer p.mu.Unlock() step := 1.0 / math.Max(p.fps, 24)
p.Seek(p.current + float64(direction)*step)
// Ensure we're paused for frame stepping
if !p.paused {
p.paused = true
}
// Calculate new position (1 frame = 1/fps seconds)
frameDuration := 1.0 / p.fps
newPos := p.current + (float64(direction) * frameDuration)
if newPos < 0 {
newPos = 0
}
p.current = newPos
p.stopLocked()
p.startLocked(p.current)
p.paused = true
// Ensure paused state sticks
time.AfterFunc(30*time.Millisecond, func() {
p.mu.Lock()
defer p.mu.Unlock()
p.paused = true
})
if p.prog != nil {
p.prog(p.current)
}
} }
// GetCurrentPosition returns the current playback position // GetCurrentPosition returns the current playback position
@ -3891,288 +3836,49 @@ func (p *playSession) Stop() {
} }
func (p *playSession) stopLocked() { func (p *playSession) stopLocked() {
select { if p.cmd != nil && p.cmd.Process != nil {
case <-p.stop: _ = p.cmd.Process.Kill()
default: _, _ = p.cmd.Process.Wait()
close(p.stop)
} }
if p.videoCmd != nil && p.videoCmd.Process != nil { p.cmd = nil
_ = p.videoCmd.Process.Kill() p.stdin = nil
_ = p.videoCmd.Wait()
}
if p.audioCmd != nil && p.audioCmd.Process != nil {
_ = p.audioCmd.Process.Kill()
_ = p.audioCmd.Wait()
}
p.videoCmd = nil
p.audioCmd = nil
p.stop = make(chan struct{})
p.done = make(chan struct{}) p.done = make(chan struct{})
} }
func (p *playSession) startLocked(offset float64) { func (p *playSession) startFFplayLocked(offset float64) {
p.paused = false
p.current = offset
p.frameN = 0
logging.Debug(logging.CatFFMPEG, "playSession start path=%s offset=%.3f fps=%.3f target=%dx%d", p.path, offset, p.fps, p.targetW, p.targetH)
p.runVideo(offset)
p.runAudio(offset)
}
func (p *playSession) runVideo(offset float64) {
fmt.Printf("📹 Starting video decode pipeline...\n")
fmt.Printf(" - Resolution: %dx%d\n", p.targetW, p.targetH)
fmt.Printf(" - Frame rate: %.2f fps\n", p.fps)
fmt.Printf(" - Offset: %.2fs\n", offset)
var stderr bytes.Buffer var stderr bytes.Buffer
args := []string{ args := []string{
"-hide_banner", "-loglevel", "error", "-hide_banner",
"-loglevel", "warning",
"-autoexit",
"-ss", fmt.Sprintf("%.3f", offset), "-ss", fmt.Sprintf("%.3f", offset),
"-i", p.path, "-i", p.path,
"-vf", fmt.Sprintf("scale=%d:%d:flags=bilinear", p.targetW, p.targetH), "-window_title", "VT Player",
"-f", "rawvideo",
"-pix_fmt", "rgb24",
"-r", fmt.Sprintf("%.3f", p.fps),
"-vsync", "0", // Avoid frame duplication
"-",
} }
fmt.Printf("🔧 FFmpeg command: ffmpeg %s\n", strings.Join(args, " ")) if p.volume > 0 {
args = append([]string{"-volume", fmt.Sprintf("%d", int(p.volume))}, args...)
cmd := exec.Command("ffmpeg", args...) }
cmd := exec.Command("ffplay", args...)
cmd.Stderr = &stderr cmd.Stderr = &stderr
stdout, err := cmd.StdoutPipe() stdin, err := cmd.StdinPipe()
if err != nil { if err != nil {
fmt.Printf("❌ ERROR: Failed to create video pipe: %v\n", err) fmt.Printf("❌ ERROR: failed to create ffplay stdin: %v\n", err)
logging.Debug(logging.CatFFMPEG, "video pipe error: %v", err)
return return
} }
cmd.Stdout = os.Stdout
fmt.Printf("⚡ Starting FFmpeg process...\n")
startTime := time.Now()
if err := cmd.Start(); err != nil { if err := cmd.Start(); err != nil {
errMsg := strings.TrimSpace(stderr.String()) fmt.Printf("❌ ERROR: ffplay failed to start: %v\n", err)
fmt.Printf("❌ ERROR: FFmpeg failed to start: %v\n", err) if msg := strings.TrimSpace(stderr.String()); msg != "" {
if errMsg != "" { fmt.Printf(" ffplay stderr: %s\n", msg)
fmt.Printf(" FFmpeg error: %s\n", errMsg)
} }
// Check if ffmpeg is available
if _, pathErr := exec.LookPath("ffmpeg"); pathErr != nil {
fmt.Printf("❌ FATAL: ffmpeg not found in PATH: %v\n", pathErr)
}
logging.Debug(logging.CatFFMPEG, "video start failed: %v (%s)", err, errMsg)
return return
} }
fmt.Printf("✅ FFmpeg started (PID: %d) in %.3fs\n", cmd.Process.Pid, time.Since(startTime).Seconds()) p.cmd = cmd
// Pace frames to the source frame rate instead of hammering refreshes as fast as possible. p.stdin = stdin
frameDur := time.Second p.paused = false
if p.fps > 0 {
frameDur = time.Duration(float64(time.Second) / math.Max(p.fps, 0.1))
}
nextFrameAt := time.Now()
p.videoCmd = cmd
frameSize := p.targetW * p.targetH * 3
buf := make([]byte, frameSize)
fmt.Printf("📦 Frame buffer allocated: %d bytes (%.2f MB)\n", frameSize, float64(frameSize)/(1024*1024))
go func() { go func() {
defer cmd.Process.Kill() _ = cmd.Wait()
close(p.done)
// Performance monitoring variables
var lastFPSReport time.Time
var fpsCounter int
loopStart := time.Now()
fmt.Printf("🔄 Frame decode loop started\n")
for {
select {
case <-p.stop:
fmt.Printf("⏹️ Video decode loop stopped\n")
logging.Debug(logging.CatFFMPEG, "video loop stop")
return
default:
}
if p.paused {
time.Sleep(30 * time.Millisecond)
nextFrameAt = time.Now().Add(frameDur)
continue
}
readStart := time.Now()
_, err := io.ReadFull(stdout, buf)
readDuration := time.Since(readStart)
if err != nil {
if errors.Is(err, io.EOF) {
fmt.Printf("📺 Video playback completed (reached end)\n")
return
}
msg := strings.TrimSpace(stderr.String())
fmt.Printf("❌ ERROR: Frame read failed: %v\n", err)
if msg != "" {
fmt.Printf(" FFmpeg error: %s\n", msg)
}
logging.Debug(logging.CatFFMPEG, "video read failed: %v (%s)", err, msg)
return
}
// Track first frame timing
if p.frameN == 0 {
elapsed := time.Since(loopStart)
fmt.Printf("🎞️ FIRST FRAME decoded in %.3fs (read: %.3fs)\n", elapsed.Seconds(), readDuration.Seconds())
}
// Improved frame pacing - use a more stable timing approach
now := time.Now()
if now.Before(nextFrameAt) {
time.Sleep(nextFrameAt.Sub(now))
}
nextFrameAt = nextFrameAt.Add(frameDur)
// Allocate a fresh frame to avoid concurrent texture reuse issues.
frame := image.NewRGBA(image.Rect(0, 0, p.targetW, p.targetH))
utils.CopyRGBToRGBA(frame.Pix, buf)
renderStart := time.Now()
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
if p.img != nil {
// Ensure we render the live frame, not a stale resource preview.
p.img.Resource = nil
p.img.File = ""
p.img.Image = frame
p.img.Refresh()
}
}, false)
renderDuration := time.Since(renderStart)
// Log first few frames in detail
if p.frameN < 3 {
fmt.Printf("🖼️ Frame %d: decode=%.3fs render=%.3fs\n", p.frameN+1, readDuration.Seconds(), renderDuration.Seconds())
logging.Debug(logging.CatFFMPEG, "video frame %d drawn (%.2fs)", p.frameN+1, p.current)
}
p.frameN++
fpsCounter++
// FPS report every 2 seconds
if time.Since(lastFPSReport) >= 2*time.Second {
fps := float64(fpsCounter) / time.Since(lastFPSReport).Seconds()
fmt.Printf("📊 Performance: %.1f FPS (target: %.1f FPS) | Frames: %d\n", fps, p.fps, p.frameN)
fpsCounter = 0
lastFPSReport = time.Now()
}
if p.fps > 0 {
p.current = offset + (float64(p.frameN) / p.fps)
}
if p.prog != nil {
p.prog(p.current)
}
}
}()
}
func (p *playSession) runAudio(offset float64) {
const sampleRate = 48000
const channels = 2
const bytesPerSample = 2
var stderr bytes.Buffer
cmd := exec.Command("ffmpeg",
"-hide_banner", "-loglevel", "error",
"-ss", fmt.Sprintf("%.3f", offset),
"-i", p.path,
"-vn",
"-ac", fmt.Sprintf("%d", channels),
"-ar", fmt.Sprintf("%d", sampleRate),
"-f", "s16le",
"-",
)
cmd.Stderr = &stderr
stdout, err := cmd.StdoutPipe()
if err != nil {
logging.Debug(logging.CatFFMPEG, "audio pipe error: %v", err)
return
}
if err := cmd.Start(); err != nil {
errMsg := strings.TrimSpace(stderr.String())
fmt.Printf("❌ ERROR: Audio FFmpeg failed to start: %v\n", err)
if errMsg != "" {
fmt.Printf(" Audio FFmpeg error: %s\n", errMsg)
}
logging.Debug(logging.CatFFMPEG, "audio start failed: %v (%s)", err, errMsg)
return
}
fmt.Printf("✅ Audio FFmpeg started (PID: %d)\n", cmd.Process.Pid)
p.audioCmd = cmd
ctx, err := getAudioContext(sampleRate, channels, bytesPerSample)
if err != nil {
logging.Debug(logging.CatFFMPEG, "audio context error: %v", err)
return
}
player := ctx.NewPlayer()
if player == nil {
logging.Debug(logging.CatFFMPEG, "audio player creation failed")
return
}
localPlayer := player
go func() {
defer cmd.Process.Kill()
defer localPlayer.Close()
chunk := make([]byte, 4096)
tmp := make([]byte, 4096)
loggedFirst := false
for {
select {
case <-p.stop:
logging.Debug(logging.CatFFMPEG, "audio loop stop")
return
default:
}
if p.paused {
time.Sleep(30 * time.Millisecond)
continue
}
n, err := stdout.Read(chunk)
if n > 0 {
if !loggedFirst {
logging.Debug(logging.CatFFMPEG, "audio stream delivering bytes")
loggedFirst = true
}
gain := p.volume / 100.0
if gain < 0 {
gain = 0
}
if gain > 2 {
gain = 2
}
copy(tmp, chunk[:n])
if p.muted || gain <= 0 {
for i := 0; i < n; i++ {
tmp[i] = 0
}
} else if math.Abs(1-gain) > 0.001 {
for i := 0; i+1 < n; i += 2 {
sample := int16(binary.LittleEndian.Uint16(tmp[i:]))
amp := int(float64(sample) * gain)
if amp > math.MaxInt16 {
amp = math.MaxInt16
}
if amp < math.MinInt16 {
amp = math.MinInt16
}
binary.LittleEndian.PutUint16(tmp[i:], uint16(int16(amp)))
}
}
localPlayer.Write(tmp[:n])
}
if err != nil {
if !errors.Is(err, io.EOF) {
logging.Debug(logging.CatFFMPEG, "audio read failed: %v (%s)", err, strings.TrimSpace(stderr.String()))
}
return
}
}
}() }()
} }