Files
galahad2lcd/internal/transcoder/transcoder.go
T
Maksim Totmin 4c34f0bedc Initial commit: Go driver for the Lian Li Galahad II LCD
Streams H.264 to the Galahad II LCD via USB (gousb) with:
- ffmpeg CLI transcoding (no fragile FFmpeg C-ABI bindings)
- pure-Go Annex-B access-unit splitting
- framerate pacing with graceful shutdown and USB reconnect
- TOML config, cobra CLI, structured slog logging, unit tests
2026-08-19 12:03:54 +07:00

229 lines
6.3 KiB
Go

// 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
}