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,117 @@
|
||||
// Package h264 parses Annex-B H.264 byte streams (as produced by libx264
|
||||
// with annexb=1) and groups the raw NAL units into access units (AUs), where
|
||||
// each AU corresponds to one decoded video frame. This matches the packet
|
||||
// boundaries the reference driver receives from FFmpeg's demuxer.
|
||||
//
|
||||
// AU grouping rules:
|
||||
//
|
||||
// - A VCL NAL (slice: types 1-5) begins a new AU.
|
||||
// - Non-VCL NALs (SPS/PPS/SEI, ...) are attached as a prefix to the AU
|
||||
// that contains the next VCL NAL, so a keyframe AU naturally becomes
|
||||
// SPS + PPS + IDR slice.
|
||||
// - An access unit delimiter (type 9) is an explicit boundary.
|
||||
package h264
|
||||
|
||||
// NAL unit types relevant for AU assembly.
|
||||
const (
|
||||
nalTypeSlice = 1
|
||||
nalTypeIDRSlice = 5
|
||||
nalTypeSEI = 6
|
||||
nalTypeSPS = 7
|
||||
nalTypePPS = 8
|
||||
nalTypeAUD = 9
|
||||
)
|
||||
|
||||
// isVCL reports whether a NAL unit type carries slice data.
|
||||
func isVCL(t byte) bool {
|
||||
return t >= nalTypeSlice && t <= nalTypeIDRSlice
|
||||
}
|
||||
|
||||
// nalType extracts the NAL unit type from the first payload byte.
|
||||
func nalType(payloadStart []byte) byte {
|
||||
if len(payloadStart) == 0 {
|
||||
return 0
|
||||
}
|
||||
return payloadStart[0] & 0x1F
|
||||
}
|
||||
|
||||
// payloadStarts returns the index of the byte immediately following each
|
||||
// start code (00 00 01, optionally prefixed with an extra 00).
|
||||
func payloadStarts(data []byte) []int {
|
||||
var starts []int
|
||||
for i := 0; i+2 < len(data); i++ {
|
||||
if data[i] == 0 && data[i+1] == 0 && data[i+2] == 1 {
|
||||
starts = append(starts, i+3)
|
||||
i = i + 2
|
||||
}
|
||||
}
|
||||
return starts
|
||||
}
|
||||
|
||||
// startCodeLen determines the length of the start code that immediately
|
||||
// precedes the payload at index pay in data.
|
||||
func startCodeLen(data []byte, pay int) int {
|
||||
if pay >= 4 && data[pay-4] == 0 && data[pay-3] == 0 && data[pay-2] == 0 && data[pay-1] == 1 {
|
||||
return 4
|
||||
}
|
||||
return 3
|
||||
}
|
||||
|
||||
// SplitAUs splits an Annex-B stream into access units. The returned slices
|
||||
// alias the input buffer, so callers must not modify data while they are in
|
||||
// use.
|
||||
func SplitAUs(data []byte) [][]byte {
|
||||
starts := payloadStarts(data)
|
||||
if len(starts) == 0 {
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
return [][]byte{data}
|
||||
}
|
||||
|
||||
// Pre-compute the byte index where each NAL (including its start code)
|
||||
// begins.
|
||||
codeStart := make([]int, len(starts))
|
||||
for i, pay := range starts {
|
||||
codeStart[i] = pay - startCodeLen(data, pay)
|
||||
}
|
||||
|
||||
var aus [][]byte
|
||||
auStart := codeStart[0]
|
||||
hasVCL := false
|
||||
|
||||
// flush ends the current AU at the start of NAL i and begins a new one.
|
||||
flush := func(i int) {
|
||||
end := codeStart[i]
|
||||
if end > auStart {
|
||||
aus = append(aus, data[auStart:end])
|
||||
}
|
||||
auStart = end
|
||||
hasVCL = false
|
||||
}
|
||||
|
||||
for i, pay := range starts {
|
||||
switch t := nalType(data[pay:]); {
|
||||
case t == nalTypeAUD:
|
||||
// An access unit delimiter is an explicit boundary; it becomes
|
||||
// the prefix of the AU that follows it.
|
||||
if hasVCL {
|
||||
flush(i)
|
||||
}
|
||||
case isVCL(t):
|
||||
// A slice always starts a new AU once the current one already
|
||||
// contains slice data.
|
||||
if hasVCL {
|
||||
flush(i)
|
||||
}
|
||||
hasVCL = true
|
||||
default:
|
||||
// Non-VCL prefix: SPS, PPS, SEI, ... attaches to the current AU.
|
||||
}
|
||||
}
|
||||
|
||||
if len(data) > auStart {
|
||||
aus = append(aus, data[auStart:])
|
||||
}
|
||||
return aus
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package h264
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// streamNALs builds an Annex-B byte stream from a list of NAL unit types.
|
||||
// Each NAL gets a minimal one-byte payload so the type is easy to assert.
|
||||
func streamNALs(types ...byte) []byte {
|
||||
var out []byte
|
||||
for _, t := range types {
|
||||
out = append(out, 0x00, 0x00, 0x01) // 3-byte start code
|
||||
out = append(out, 0x60|t) // nal_ref_idc=3, type in low bits
|
||||
out = append(out, 0x00)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// firstNALType extracts the type of the first NAL in a byte slice.
|
||||
func firstNALType(au []byte) byte {
|
||||
if len(au) < 4 {
|
||||
return 0
|
||||
}
|
||||
// Skip the start code: 3 or 4 bytes.
|
||||
pay := 3
|
||||
if au[0] == 0 && au[1] == 0 && au[2] == 0 {
|
||||
pay = 4
|
||||
}
|
||||
return au[pay] & 0x1F
|
||||
}
|
||||
|
||||
func typesOf(aus [][]byte) [][]byte {
|
||||
var types [][]byte
|
||||
for _, au := range aus {
|
||||
var ts []byte
|
||||
rest := au
|
||||
for len(rest) > 0 {
|
||||
ts = append(ts, firstNALType(rest))
|
||||
// advance past this NAL (start code + payload of >=1 byte)
|
||||
skip := 4
|
||||
if rest[0] == 0 && rest[1] == 0 && rest[2] == 0 {
|
||||
skip = 5
|
||||
}
|
||||
// find next start code
|
||||
next := bytes.Index(rest[skip:], []byte{0x00, 0x00, 0x01})
|
||||
if next < 0 {
|
||||
break
|
||||
}
|
||||
rest = rest[skip+next:]
|
||||
}
|
||||
types = append(types, ts)
|
||||
}
|
||||
return types
|
||||
}
|
||||
|
||||
func TestSplitAUsEmpty(t *testing.T) {
|
||||
if got := SplitAUs(nil); got != nil {
|
||||
t.Errorf("SplitAUs(nil) = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitAUsNoStartCode(t *testing.T) {
|
||||
data := []byte{0x67, 0x42, 0x00}
|
||||
got := SplitAUs(data)
|
||||
if len(got) != 1 || !bytes.Equal(got[0], data) {
|
||||
t.Errorf("SplitAUs passthrough = %v, want [% x]", got, data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitAUsKeyframeThenP(t *testing.T) {
|
||||
// SPS(7), PPS(8), IDR(5), P(1), P(1)
|
||||
stream := streamNALs(7, 8, 5, 1, 1)
|
||||
aus := SplitAUs(stream)
|
||||
if len(aus) != 3 {
|
||||
t.Fatalf("got %d AUs, want 3", len(aus))
|
||||
}
|
||||
types := typesOf(aus)
|
||||
want := [][]byte{{7, 8, 5}, {1}, {1}}
|
||||
for i := range want {
|
||||
if !bytes.Equal(types[i], want[i]) {
|
||||
t.Errorf("AU %d types = %v, want %v", i, types[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitAUsWithAUD(t *testing.T) {
|
||||
// AUD(9), IDR(5), P(1)
|
||||
stream := streamNALs(9, 5, 1)
|
||||
aus := SplitAUs(stream)
|
||||
if len(aus) != 2 {
|
||||
t.Fatalf("got %d AUs, want 2", len(aus))
|
||||
}
|
||||
types := typesOf(aus)
|
||||
want := [][]byte{{9, 5}, {1}}
|
||||
for i := range want {
|
||||
if !bytes.Equal(types[i], want[i]) {
|
||||
t.Errorf("AU %d types = %v, want %v", i, types[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitAUsAUDThenPrefix(t *testing.T) {
|
||||
// AUD(9), SPS(7), PPS(8), IDR(5), P(1)
|
||||
stream := streamNALs(9, 7, 8, 5, 1)
|
||||
aus := SplitAUs(stream)
|
||||
if len(aus) != 2 {
|
||||
t.Fatalf("got %d AUs, want 2", len(aus))
|
||||
}
|
||||
types := typesOf(aus)
|
||||
want := [][]byte{{9, 7, 8, 5}, {1}}
|
||||
for i := range want {
|
||||
if !bytes.Equal(types[i], want[i]) {
|
||||
t.Errorf("AU %d types = %v, want %v", i, types[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitAUsFourByteCodes(t *testing.T) {
|
||||
var stream []byte
|
||||
for _, t := range []byte{7, 8, 5, 1} {
|
||||
stream = append(stream, 0x00, 0x00, 0x00, 0x01) // 4-byte
|
||||
stream = append(stream, 0x60|t, 0x00)
|
||||
}
|
||||
aus := SplitAUs(stream)
|
||||
if len(aus) != 2 {
|
||||
t.Fatalf("got %d AUs, want 2", len(aus))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitAUsMixedCodes(t *testing.T) {
|
||||
var stream []byte
|
||||
stream = append(stream, 0x00, 0x00, 0x01, 0x67, 0x00) // 3-byte SPS
|
||||
stream = append(stream, 0x00, 0x00, 0x00, 0x01, 0x68, 0x00) // 4-byte PPS
|
||||
stream = append(stream, 0x00, 0x00, 0x00, 0x01, 0x65, 0x00) // 4-byte IDR
|
||||
stream = append(stream, 0x00, 0x00, 0x01, 0x41, 0x00) // 3-byte P
|
||||
|
||||
aus := SplitAUs(stream)
|
||||
if len(aus) != 2 {
|
||||
t.Fatalf("got %d AUs, want 2", len(aus))
|
||||
}
|
||||
types := typesOf(aus)
|
||||
want := [][]byte{{7, 8, 5}, {1}}
|
||||
for i := range want {
|
||||
if !bytes.Equal(types[i], want[i]) {
|
||||
t.Errorf("AU %d types = %v, want %v", i, types[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitAUsReassemblesStream(t *testing.T) {
|
||||
stream := streamNALs(7, 8, 5, 1, 1, 7, 8, 5, 1)
|
||||
aus := SplitAUs(stream)
|
||||
var rebuilt []byte
|
||||
for _, au := range aus {
|
||||
rebuilt = append(rebuilt, au...)
|
||||
}
|
||||
if !bytes.Equal(rebuilt, stream) {
|
||||
t.Error("reassembled stream differs from input")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSplitAUsRealFile validates against a real libx264 Annex-B stream.
|
||||
func TestSplitAUsRealFile(t *testing.T) {
|
||||
path := filepath.Join("..", "..", "testdata", "sample.h264")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Skipf("testdata not available: %v", err)
|
||||
}
|
||||
|
||||
aus := SplitAUs(data)
|
||||
if len(aus) < 2 {
|
||||
t.Fatalf("got %d AUs, want >= 2", len(aus))
|
||||
}
|
||||
|
||||
// First AU must be a keyframe: SPS + PPS + optional prefix NALs (SEI)
|
||||
// followed by an IDR slice.
|
||||
first := typesOf(aus[:1])[0]
|
||||
if len(first) < 3 || first[0] != 7 || first[1] != 8 || first[len(first)-1] != 5 {
|
||||
t.Errorf("first AU types = %v, want prefix [7 8 ... 5]", first)
|
||||
}
|
||||
|
||||
// All AUs must be non-empty and start with a valid start code.
|
||||
for i, au := range aus {
|
||||
if len(au) < 4 {
|
||||
t.Fatalf("AU %d too short: %d bytes", i, len(au))
|
||||
}
|
||||
if !(au[0] == 0 && au[1] == 0 && (au[2] == 0 || au[2] == 1)) {
|
||||
t.Errorf("AU %d does not start with a start code: % x", i, au[:4])
|
||||
}
|
||||
}
|
||||
|
||||
// Reassembly must reproduce the input exactly.
|
||||
var rebuilt []byte
|
||||
for _, au := range aus {
|
||||
rebuilt = append(rebuilt, au...)
|
||||
}
|
||||
if !bytes.Equal(rebuilt, data) {
|
||||
t.Error("reassembled stream differs from input")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user