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
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package transcoder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"galahad2lcd/internal/config"
|
||||
)
|
||||
|
||||
func TestBuildFilter(t *testing.T) {
|
||||
tests := []struct {
|
||||
rotate int
|
||||
fps float64
|
||||
want string
|
||||
}{
|
||||
{0, 30, "scale=480:480:flags=lanczos,format=yuv420p"},
|
||||
{90, 30, "scale=480:480:flags=lanczos,transpose=1,format=yuv420p"},
|
||||
{180, 30, "scale=480:480:flags=lanczos,transpose=1,transpose=1,format=yuv420p"},
|
||||
{270, 30, "scale=480:480:flags=lanczos,transpose=2,format=yuv420p"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := buildFilter(tt.rotate, tt.fps); got != tt.want {
|
||||
t.Errorf("buildFilter(%d, %v) = %q, want %q", tt.rotate, tt.fps, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFPSRational(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
want float64
|
||||
err bool
|
||||
}{
|
||||
{"30000/1001", 30000.0 / 1001, false},
|
||||
{"25", 25, false},
|
||||
{"0/0", 0, true},
|
||||
{"", 0, true},
|
||||
{"bogus", 0, true},
|
||||
{"10/2", 5, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got, err := parseFPSRational(tt.in)
|
||||
if (err != nil) != tt.err {
|
||||
t.Errorf("parseFPSRational(%q) error = %v, wantErr = %v", tt.in, err, tt.err)
|
||||
continue
|
||||
}
|
||||
if !tt.err && got != tt.want {
|
||||
t.Errorf("parseFPSRational(%q) = %v, want %v", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClampFPS(t *testing.T) {
|
||||
if got := clampFPS(0); got != defaultFPS {
|
||||
t.Errorf("clampFPS(0) = %v, want %v", got, defaultFPS)
|
||||
}
|
||||
if got := clampFPS(300); got != 120 {
|
||||
t.Errorf("clampFPS(300) = %v, want 120", got)
|
||||
}
|
||||
if got := clampFPS(24); got != 24 {
|
||||
t.Errorf("clampFPS(24) = %v, want 24", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTranscodeIntegration exercises the real ffmpeg path when available.
|
||||
func TestTranscodeIntegration(t *testing.T) {
|
||||
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
||||
t.Skip("ffmpeg not installed")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
input := filepath.Join(dir, "src.gif")
|
||||
cache := filepath.Join(dir, "out.h264")
|
||||
|
||||
// Build a tiny valid GIF with ffmpeg's testsrc2 source.
|
||||
if err := run(context.Background(), "ffmpeg",
|
||||
"-y", "-v", "error",
|
||||
"-f", "lavfi", "-i", "testsrc2=size=480x480:rate=10:duration=1",
|
||||
input); err != nil {
|
||||
t.Fatalf("generate source gif: %v", err)
|
||||
}
|
||||
|
||||
tc := NewWithBinaries("ffmpeg", "ffprobe")
|
||||
cfg := config.Default()
|
||||
cfg.Display.Input = input
|
||||
cfg.Display.Rotate = 90
|
||||
cfg.Stream.CachePath = cache
|
||||
|
||||
fps, err := tc.Transcode(context.Background(), cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("Transcode error = %v", err)
|
||||
}
|
||||
if fps != 10 {
|
||||
t.Errorf("fps = %v, want 10", fps)
|
||||
}
|
||||
|
||||
st, err := os.Stat(cache)
|
||||
if err != nil {
|
||||
t.Fatalf("cache file missing: %v", err)
|
||||
}
|
||||
if st.Size() == 0 {
|
||||
t.Error("cache file empty")
|
||||
}
|
||||
if !strings.HasPrefix(readFirstBytes(t, cache), "\x00\x00\x00\x01") {
|
||||
t.Error("cache file does not start with an Annex-B start code")
|
||||
}
|
||||
}
|
||||
|
||||
func readFirstBytes(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read cache: %v", err)
|
||||
}
|
||||
if len(data) < 4 {
|
||||
return string(data)
|
||||
}
|
||||
return string(data[:4])
|
||||
}
|
||||
Reference in New Issue
Block a user