-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathnode_encode.go
More file actions
252 lines (224 loc) · 8.32 KB
/
Copy pathnode_encode.go
File metadata and controls
252 lines (224 loc) · 8.32 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
package switcher
import (
"fmt"
"log/slog"
"runtime/debug"
"sync"
"sync/atomic"
"time"
"github.com/zsiec/prism/media"
"github.com/zsiec/switchframe/server/internal/atomicutil"
"github.com/zsiec/switchframe/server/metrics"
)
var _ PipelineNode = (*encodeNode)(nil)
var _ AsyncMetricsProvider = (*encodeNode)(nil)
// encodeWork holds a frame and encode parameters for async processing.
type encodeWork struct {
frame *ProcessingFrame
forceIDR bool
}
type encodeNode struct {
codecs *pipelineCodecs
forceIDR *atomic.Bool
promMetrics *metrics.Metrics
lastErr atomic.Value // stores error; safe for concurrent Snapshot() reads
// Output callback -- called with encoded H.264 frame.
onEncoded func(frame *media.VideoFrame)
// Diagnostic counter for hardware encoder warmup (nil frame returns)
encodeNilCount *atomic.Int64
// Async encode goroutine. When encodeCh is non-nil, Process() enqueues
// work and returns immediately — the encode happens in a background
// goroutine. This decouples encode latency from the pipeline loop,
// allowing raw sinks (which run before encode) to deliver frames at
// full rate even when encode is slow (e.g., stinger transitions).
encodeCh chan encodeWork
wg sync.WaitGroup
closeOnce sync.Once
// Diagnostic counter: frames dropped because the encoder goroutine
// was still busy with the previous frame.
encodeDropCount *atomic.Int64
// Async encode timing — written by encodeLoop, read by AsyncMetrics().
lastEncodeNs atomic.Int64 // most recent real encode duration (nanoseconds)
maxEncodeNs atomic.Int64 // peak encode duration (nanoseconds)
encodeTotal atomic.Int64 // total frames encoded successfully
// encoder10bit is true when the encoder accepts YUV422P10LE input directly
// (e.g., HEVC main422-10 or main10 profile). When true, 10-bit frames are
// passed to the encoder without downconversion.
encoder10bit bool
// downconvertBuf is a preallocated buffer for 10-bit→8-bit conversion
// when the encoder expects YUV420 8-bit. Allocated lazily on first use.
downconvertBuf []byte
}
func (n *encodeNode) Name() string { return "h264-encode" }
func (n *encodeNode) Configure(format PipelineFormat) error { return nil }
func (n *encodeNode) Active() bool { return true }
func (n *encodeNode) Err() error {
if v := n.lastErr.Load(); v != nil {
return v.(error)
}
return nil
}
func (n *encodeNode) Latency() time.Duration { return 10 * time.Millisecond }
// AsyncMetrics returns real encode timing measured inside the async goroutine.
// This is the actual H.264 encode wall time, not the near-zero Process() enqueue time.
func (n *encodeNode) AsyncMetrics() map[string]any {
m := map[string]any{
"encode_last_ns": n.lastEncodeNs.Load(),
"encode_max_ns": n.maxEncodeNs.Load(),
"encode_total": n.encodeTotal.Load(),
}
if n.encodeCh != nil {
m["encode_queue_len"] = len(n.encodeCh)
} else {
m["encode_queue_len"] = 0
}
return m
}
// start launches the async encode goroutine. Must be called before Process()
// for async operation. If not called, Process() falls back to synchronous encode.
func (n *encodeNode) start() {
// Buffer of 4: absorbs encode latency spikes without dropping.
// At 30fps (33ms budget), this allows bursts where multiple frames
// arrive before the encoder finishes the current one. Keeps latency
// bounded to ~3 extra frames (100ms) in the worst case. Previously
// 2, but software x264 on single-core VPS dropped 3-4% of frames.
n.encodeCh = make(chan encodeWork, 4)
n.wg.Add(1)
go n.encodeLoop()
}
// Close stops the async encode goroutine and waits for pending work to drain.
// Safe to call multiple times (idempotent via sync.Once).
//
// Thread safety with Process(): Pipeline.Close() calls inflight.Wait() before
// closing nodes, guaranteeing no concurrent Process() call is in progress when
// Close() runs. This is enforced by the Pipeline.Run()/Close() contract.
func (n *encodeNode) Close() error {
n.closeOnce.Do(func() {
if n.encodeCh != nil {
close(n.encodeCh)
n.wg.Wait()
}
})
return nil
}
// encodeLoop processes encode work items from the channel.
// Runs in a dedicated goroutine for the lifetime of the node.
func (n *encodeNode) encodeLoop() {
defer n.wg.Done()
for work := range n.encodeCh {
n.processWorkItem(work)
}
}
// processWorkItem encodes a single frame with panic recovery.
// A panic in the encoder (e.g., cgo FFmpeg crash) must not kill the
// goroutine — that would silently disable all H.264 output.
func (n *encodeNode) processWorkItem(work encodeWork) {
defer func() {
if r := recover(); r != nil {
n.lastErr.Store(fmt.Errorf("encode panic: %v", r))
slog.Error("encode goroutine recovered from panic",
"panic", r,
"stack", string(debug.Stack()))
// Invalidate the encoder — a panic (e.g., cgo crash) likely
// left it in a corrupt state. Force recreation on next frame.
n.codecs.invalidateEncoder()
}
work.frame.ReleaseYUV() // always release the async ref
}()
n.doEncode(work.frame, work.forceIDR)
}
// doEncode performs the actual encode with timing, metrics, and error handling.
// Called from processWorkItem (async) or Process (sync fallback).
//
// When the frame is 10-bit (YUV422P10LE) and the encoder accepts 10-bit input
// (e.g., HEVC main422-10 profile), the frame is passed directly. Otherwise,
// it is downconverted to 8-bit (YUV420P) before encoding. The downconvert
// buffer is reused across frames to avoid per-frame allocation.
func (n *encodeNode) doEncode(src *ProcessingFrame, forceIDR bool) {
encSrc := src
if src.Format == ColorYUV422_10bit && !n.encoder10bit {
// Encoder expects 8-bit input — downconvert.
dstSize := ColorYUV420_8bit.FrameSize(src.Width, src.Height)
if len(n.downconvertBuf) < dstSize {
n.downconvertBuf = make([]byte, dstSize)
}
if err := Downconvert422_10to420(src.Data, n.downconvertBuf[:dstSize], src.Width, src.Height); err == nil {
encSrc = &ProcessingFrame{
Data: n.downconvertBuf[:dstSize],
Format: ColorYUV420_8bit,
Width: src.Width,
Height: src.Height,
PTS: src.PTS,
DTS: src.DTS,
IsKeyframe: src.IsKeyframe,
GroupID: src.GroupID,
Codec: src.Codec,
}
}
// If downconvert fails, fall through and let the encoder try with the original data.
}
encStart := time.Now().UnixNano()
frame, err := n.codecs.encode(encSrc, forceIDR)
encDur := time.Now().UnixNano() - encStart
// Store real encode timing for debug snapshot (AsyncMetrics).
n.lastEncodeNs.Store(encDur)
atomicutil.UpdateMax(&n.maxEncodeNs, encDur)
if n.promMetrics != nil {
n.promMetrics.PipelineEncodeDuration.Observe(float64(encDur) / 1e9)
}
if err != nil {
n.lastErr.Store(err)
if n.promMetrics != nil {
n.promMetrics.PipelineEncodeErrorsTotal.Inc()
}
return
}
if frame == nil {
if n.encodeNilCount != nil {
n.encodeNilCount.Add(1)
}
return
}
n.encodeTotal.Add(1)
if n.promMetrics != nil {
n.promMetrics.PipelineFramesProcessed.Inc()
}
if n.onEncoded != nil {
n.onEncoded(frame)
}
}
func (n *encodeNode) Process(dst, src *ProcessingFrame) *ProcessingFrame {
forceIDR := src.IsKeyframe
if n.forceIDR != nil {
forceIDR = forceIDR || n.forceIDR.CompareAndSwap(true, false)
}
// Async path: enqueue work for the background goroutine.
// Only use async when the frame has ref tracking (managed frames).
// Unmanaged frames (refs == nil, e.g. test code) can't safely Ref(),
// so they fall through to synchronous encode.
if n.encodeCh != nil && src.Refs() > 0 {
src.Ref() // +1 for async encode goroutine
select {
case n.encodeCh <- encodeWork{frame: src, forceIDR: forceIDR}:
// queued for async encode
default:
// Encoder goroutine still busy — drop this frame from H.264 output.
// Raw YUV sinks already received this frame (they run before encode
// in the pipeline). H.264 viewers will experience the same drop they
// would have seen from videoProcCh backpressure before this change.
src.ReleaseYUV()
if forceIDR && n.forceIDR != nil {
// Re-arm so the next frame carries the IDR request.
n.forceIDR.Store(true)
}
if n.encodeDropCount != nil {
n.encodeDropCount.Add(1)
}
}
return src
}
// Synchronous fallback: no start() called, or frame is unmanaged
// (no ref tracking). Used in tests and for transient frames.
n.doEncode(src, forceIDR)
return src
}