// Package transcoder converts a source video or GIF into a raw Annex-B // H.264 stream using the system ffmpeg/ffprobe binaries. Shelling out to // ffmpeg avoids the fragile C-ABI bindings that plague in-process encoders // and lets this driver work with any recent FFmpeg release. package transcoder import ( "context" "errors" "fmt" "os" "os/exec" "strconv" "strings" "galahad2lcd/internal/config" ) // Screen dimensions of the Lian Li Galahad II LCD. const ( screenWidth = 480 screenHeight = 480 ) // encoderParameters mirrors the reference driver: constrained VBR, low // latency and a stream that the display firmware accepts. const ( bitRate = 2000 // kbit/s preset = "veryfast" profile = "baseline" defaultFPS = 30.0 ) // Transcoder invokes ffmpeg and ffprobe. Binary paths are configurable so // tests can substitute fakes. type Transcoder struct { ffmpeg string ffprobe string } // New returns a Transcoder that resolves ffmpeg and ffprobe from PATH. func New() (*Transcoder, error) { ffmpeg, err := exec.LookPath("ffmpeg") if err != nil { return nil, fmt.Errorf("ffmpeg not found: %w (install ffmpeg)", err) } ffprobe, err := exec.LookPath("ffprobe") if err != nil { return nil, fmt.Errorf("ffprobe not found: %w (install ffmpeg)", err) } return &Transcoder{ffmpeg: ffmpeg, ffprobe: ffprobe}, nil } // NewWithBinaries is intended for tests. func NewWithBinaries(ffmpeg, ffprobe string) *Transcoder { return &Transcoder{ffmpeg: ffmpeg, ffprobe: ffprobe} } // Transcode renders the configured media into an Annex-B H.264 cache file // and returns the effective playback framerate. It is cancellable via ctx. func (t *Transcoder) Transcode(ctx context.Context, cfg config.Config) (float64, error) { if cfg.Display.Input == "" { return 0, fmt.Errorf("no input file configured") } // Remove any stale cache file first: it may be owned by another user // (e.g. created by a manual run) which would make the service fail to // open it for writing. ffmpeg -y alone cannot fix that. if err := os.Remove(cfg.Stream.CachePath); err != nil && !errors.Is(err, os.ErrNotExist) { return 0, fmt.Errorf("remove stale cache %q: %w", cfg.Stream.CachePath, err) } fps := cfg.Stream.FPS if fps <= 0 { detected, err := t.DetectFPS(ctx, cfg.Display.Input) if err != nil { return 0, err } fps = detected } fps = clampFPS(fps) gop := max(1, int(fps+0.5)) vf := buildFilter(cfg.Display.Rotate, fps) args := []string{ "-y", "-v", "error", "-i", cfg.Display.Input, "-vf", vf, "-c:v", "libx264", "-preset", preset, "-profile:v", profile, "-b:v", strconv.Itoa(bitRate) + "k", "-maxrate", strconv.Itoa(bitRate) + "k", "-bufsize", strconv.Itoa(bitRate) + "k", "-x264-params", fmt.Sprintf( "nal-hrd=cbr:annexb=1:open-gop=0:scenecut=0:keyint=%d:min-keyint=%d", gop, gop), "-an", "-f", "h264", cfg.Stream.CachePath, } if err := run(ctx, t.ffmpeg, args...); err != nil { return 0, fmt.Errorf("transcode %q: %w", cfg.Display.Input, err) } return fps, nil } // DetectFPS probes the source video stream and returns its average frame // rate. It falls back to a safe default when the rate cannot be determined. func (t *Transcoder) DetectFPS(ctx context.Context, input string) (float64, error) { out, err := runCapture(ctx, t.ffprobe, "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=avg_frame_rate", "-of", "csv=p=0", input) if err != nil { return 0, fmt.Errorf("probe %q: %w", input, err) } fps, err := parseFPSRational(strings.TrimSpace(out)) if err != nil { // Streams without a meaningful avg_frame_rate (some GIFs) report 0/0. if r, rerr := t.rateFromHeader(ctx, input); rerr == nil && r > 0 { return r, nil } return defaultFPS, nil } return fps, nil } // rateFromHeader reads the raw codec frame rate as a secondary probe. func (t *Transcoder) rateFromHeader(ctx context.Context, input string) (float64, error) { out, err := runCapture(ctx, t.ffprobe, "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=r_frame_rate", "-of", "csv=p=0", input) if err != nil { return 0, err } return parseFPSRational(strings.TrimSpace(out)) } // buildFilter assembles the video filter graph: scale to the display, apply // the requested rotation, normalise to the target frame rate and force a // format libx264 accepts. func buildFilter(rotate int, fps float64) string { var parts []string parts = append(parts, fmt.Sprintf("scale=%d:%d:flags=lanczos", screenWidth, screenHeight)) switch rotate { case 90: parts = append(parts, "transpose=1") // 90° clockwise case 180: parts = append(parts, "transpose=1,transpose=1") case 270: parts = append(parts, "transpose=2") // 90° counter-clockwise } parts = append(parts, "format=yuv420p") return strings.Join(parts, ",") } // parseFPSRational parses an ffprobe frame rate such as "30000/1001", // "25" or "0/0". func parseFPSRational(s string) (float64, error) { s = strings.TrimSpace(s) if s == "" { return 0, fmt.Errorf("empty frame rate") } num, den := int64(1), int64(1) if i := strings.IndexByte(s, '/'); i >= 0 { var err error if num, err = strconv.ParseInt(s[:i], 10, 64); err != nil { return 0, fmt.Errorf("parse numerator %q: %w", s[:i], err) } if den, err = strconv.ParseInt(s[i+1:], 10, 64); err != nil { return 0, fmt.Errorf("parse denominator %q: %w", s[i+1:], err) } } else { n, err := strconv.ParseFloat(s, 64) if err != nil { return 0, fmt.Errorf("parse rate %q: %w", s, err) } return n, nil } if num <= 0 || den <= 0 { return 0, fmt.Errorf("non-positive frame rate %q", s) } return float64(num) / float64(den), nil } func clampFPS(fps float64) float64 { switch { case fps <= 0: return defaultFPS case fps > 120: return 120 default: return fps } } func run(ctx context.Context, bin string, args ...string) error { cmd := exec.CommandContext(ctx, bin, args...) if out, err := cmd.CombinedOutput(); err != nil { msg := strings.TrimSpace(string(out)) if msg != "" { return fmt.Errorf("%s: %w", msg, err) } return err } return nil } func runCapture(ctx context.Context, bin string, args ...string) (string, error) { cmd := exec.CommandContext(ctx, bin, args...) out, err := cmd.CombinedOutput() if err != nil { return "", err } return string(out), nil } func max(a, b int) int { if a > b { return a } return b }