detached ee321a7

fix: address CodeRabbit findings for transcoding pipeline

- Safe type assertion in transcoding_handler (comma-ok idiom)
- Add 5-minute timeout to transcode sessions (prevent orphaned sessions)
- Remove dead code in audio.go (unused outFlags/oformat)
- Add HTTP connect/TLS/header timeouts (prevent infinite hang)
- Reuse audio frame/packet allocations (reduce GC pressure)
- Read directly into CEF buffer in ResourceHandler.Read (zero-copy)
- Add panic recovery in pipeline goroutine (prevent session slot leaks)
- Log resampling errors during audio flush (don't swallow silently)
- Use RWMutex for session pool count()
- Add Close() to MediaTranscoder port interface
- Fix BufferUnref calls for new *unsafe.Pointer signature
- Remove unused unsafe import from hwprobe.go
- Update detect.go comments to be engine-agnostic
commit
ee321a7a61676e933314b067689a32f7244c343e
author
brice · · 2026-03-24 06:59 UTC
committer
brice · · 2026-03-28 04:39 UTC
parent
052bd05
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +100 -58
M internal/application/port/transcoder.go +2 -0
@@ -30,4 +30,6 @@ type MediaTranscoder interface {
3030 // Start begins a GPU transcode session, fetching from sourceURL.
3131 // Headers are forwarded to the source HTTP request (cookies, auth).
3232 Start(ctx context.Context, sourceURL string, headers map[string]string) (TranscodeSession, error)
33+ // Close shuts down all active transcode sessions and releases resources.
34+ Close()
3335 }
@@ -30,4 +30,6 @@ type MediaTranscoder interface {
30 // Start begins a GPU transcode session, fetching from sourceURL.30 // Start begins a GPU transcode session, fetching from sourceURL.
31 // Headers are forwarded to the source HTTP request (cookies, auth).31 // Headers are forwarded to the source HTTP request (cookies, auth).
32 Start(ctx context.Context, sourceURL string, headers map[string]string) (TranscodeSession, error)32 Start(ctx context.Context, sourceURL string, headers map[string]string) (TranscodeSession, error)
33+ // Close shuts down all active transcode sessions and releases resources.
34+ Close()
33 }35 }
M internal/infrastructure/cef/transcoding_handler.go +8 -6
@@ -3,6 +3,7 @@ package cef
33 import (
44 "context"
55 "sync"
6+ "time"
67 "unsafe"
78
89 purecef "github.com/bnema/purego-cef/cef"
@@ -72,11 +73,9 @@ func (rh *transcodingResourceHandler) Read(
7273 return 0
7374 }
7475
75- buf := make([]byte, bytesToRead)
76- n, err := rh.session.Read(buf)
76+ dst := unsafe.Slice((*byte)(dataOut), int(bytesToRead))
77+ n, err := rh.session.Read(dst)
7778 if n > 0 {
78- dst := unsafe.Slice((*byte)(dataOut), n)
79- copy(dst, buf[:n])
8079 *(*int32)(bytesRead) = int32(n)
8180 return 1
8281 }
@@ -177,9 +176,12 @@ func (h *transcodingRequestHandler) GetResourceHandler(_ purecef.Browser, _ pure
177176 if !ok {
178177 return nil
179178 }
180- info := val.(requestInfo)
179+ info, ok := val.(requestInfo)
180+ if !ok {
181+ return nil
182+ }
181183
182- ctx, cancel := context.WithCancel(context.Background())
184+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
183185 return purecef.NewResourceHandler(&transcodingResourceHandler{
184186 transcoder: h.transcoder,
185187 sourceURL: url,
@@ -3,6 +3,7 @@ package cef
3 import (3 import (
4 "context"4 "context"
5 "sync"5 "sync"
6+ "time"
6 "unsafe"7 "unsafe"
7 8
8 purecef "github.com/bnema/purego-cef/cef"9 purecef "github.com/bnema/purego-cef/cef"
@@ -72,11 +73,9 @@ func (rh *transcodingResourceHandler) Read(
72 return 073 return 0
73 }74 }
74 75
75- buf := make([]byte, bytesToRead)76+ dst := unsafe.Slice((*byte)(dataOut), int(bytesToRead))
76- n, err := rh.session.Read(buf)77+ n, err := rh.session.Read(dst)
77 if n > 0 {78 if n > 0 {
78- dst := unsafe.Slice((*byte)(dataOut), n)
79- copy(dst, buf[:n])
80 *(*int32)(bytesRead) = int32(n)79 *(*int32)(bytesRead) = int32(n)
81 return 180 return 1
82 }81 }
@@ -177,9 +176,12 @@ func (h *transcodingRequestHandler) GetResourceHandler(_ purecef.Browser, _ pure
177 if !ok {176 if !ok {
178 return nil177 return nil
179 }178 }
180- info := val.(requestInfo)179+ info, ok := val.(requestInfo)
180+ if !ok {
181+ return nil
182+ }
181 183
182- ctx, cancel := context.WithCancel(context.Background())184+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
183 return purecef.NewResourceHandler(&transcodingResourceHandler{185 return purecef.NewResourceHandler(&transcodingResourceHandler{
184 transcoder: h.transcoder,186 transcoder: h.transcoder,
185 sourceURL: url,187 sourceURL: url,
M internal/infrastructure/transcoder/audio.go +48 -30
@@ -6,6 +6,7 @@ import (
66 "unsafe"
77
88 "github.com/bnema/purego-ffmpeg/ffmpeg"
9+ "github.com/rs/zerolog"
910 )
1011
1112 // opusSampleRate is the standard output sample rate for Opus encoding.
@@ -26,6 +27,8 @@ type audioTranscoder struct {
2627 encoder unsafe.Pointer // AVCodecContext* for Opus encoding
2728 resampler unsafe.Pointer // SwrContext* (nil if no resampling needed)
2829 frame unsafe.Pointer // reusable AVFrame for resampled output
30+ decFrame unsafe.Pointer // reusable AVFrame for decoded output
31+ encPkt unsafe.Pointer // reusable AVPacket for encoded output
2932 inStream int // input stream index in the source container
3033 outStream int // output stream index in the muxer
3134 }
@@ -81,13 +84,7 @@ func newAudioTranscoder(inCodecPar, outFmtCtx unsafe.Pointer, inStreamIdx int) (
8184 // Set time base to 1/sample_rate for audio.
8285 ffmpeg.CodecCtxSetTimeBase(encCtx, ffmpeg.AVRational{Num: 1, Den: opusSampleRate})
8386
84- // If the output format requires global headers, set the flag on the encoder.
85- outFlags := ffmpeg.FmtCtxFlags(outFmtCtx)
86- oformat := ffmpeg.FmtCtxOformat(outFmtCtx)
87- if oformat != nil {
88- // Check output format flags via the format context flags.
89- _ = outFlags // Format flags are on FmtCtx, not oformat.
90- }
87+ // Note: WebM container does not require AV_CODEC_FLAG_GLOBAL_HEADER for Opus.
9188
9289 if ret := ffmpeg.CodecOpen2(encCtx, enc, nil); ret < 0 {
9390 ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx))
@@ -161,11 +158,40 @@ func newAudioTranscoder(inCodecPar, outFmtCtx unsafe.Pointer, inStreamIdx int) (
161158 codecCtxCopyChLayout(resampledFrame, decCtx)
162159 }
163160
161+ decFrame := ffmpeg.FrameAlloc()
162+ if decFrame == nil {
163+ if resampledFrame != nil {
164+ ffmpeg.FrameFree(unsafe.Pointer(&resampledFrame))
165+ }
166+ if swr != nil {
167+ ffmpeg.SwrFree(unsafe.Pointer(&swr))
168+ }
169+ ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx))
170+ ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx))
171+ return nil, errors.New("failed to allocate audio decode frame")
172+ }
173+
174+ encPkt := ffmpeg.PacketAlloc()
175+ if encPkt == nil {
176+ ffmpeg.FrameFree(unsafe.Pointer(&decFrame))
177+ if resampledFrame != nil {
178+ ffmpeg.FrameFree(unsafe.Pointer(&resampledFrame))
179+ }
180+ if swr != nil {
181+ ffmpeg.SwrFree(unsafe.Pointer(&swr))
182+ }
183+ ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx))
184+ ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx))
185+ return nil, errors.New("failed to allocate audio encode packet")
186+ }
187+
164188 return &audioTranscoder{
165189 decoder: decCtx,
166190 encoder: encCtx,
167191 resampler: swr,
168192 frame: resampledFrame,
193+ decFrame: decFrame,
194+ encPkt: encPkt,
169195 inStream: inStreamIdx,
170196 outStream: outStreamIdx,
171197 }, nil
@@ -180,17 +206,8 @@ func (a *audioTranscoder) processPacket(pkt, outFmtCtx unsafe.Pointer) error {
180206 return fmt.Errorf("audio decode send packet: %d", ret)
181207 }
182208
183- frame := ffmpeg.FrameAlloc()
184- if frame == nil {
185- return errors.New("failed to allocate audio decode frame")
186- }
187- defer ffmpeg.FrameFree(unsafe.Pointer(&frame))
188-
189- encPkt := ffmpeg.PacketAlloc()
190- if encPkt == nil {
191- return errors.New("failed to allocate audio encode packet")
192- }
193- defer ffmpeg.PacketFree(unsafe.Pointer(&encPkt))
209+ frame := a.decFrame
210+ encPkt := a.encPkt
194211
195212 for {
196213 ret = ffmpeg.CodecReceiveFrame(a.decoder, frame)
@@ -272,21 +289,14 @@ func (a *audioTranscoder) processPacket(pkt, outFmtCtx unsafe.Pointer) error {
272289 }
273290
274291 // flush drains remaining frames from the decoder and encoder.
275-func (a *audioTranscoder) flush(outFmtCtx unsafe.Pointer) error {
292+// The logger parameter is used to report non-fatal resampling errors
293+// that occur during flush (we continue flushing rather than aborting).
294+func (a *audioTranscoder) flush(outFmtCtx unsafe.Pointer, logger zerolog.Logger) error {
276295 // Flush decoder by sending NULL packet.
277296 ffmpeg.CodecSendPacket(a.decoder, nil)
278297
279- frame := ffmpeg.FrameAlloc()
280- if frame == nil {
281- return errors.New("failed to allocate frame for audio flush")
282- }
283- defer ffmpeg.FrameFree(unsafe.Pointer(&frame))
284-
285- encPkt := ffmpeg.PacketAlloc()
286- if encPkt == nil {
287- return errors.New("failed to allocate packet for audio flush")
288- }
289- defer ffmpeg.PacketFree(unsafe.Pointer(&encPkt))
298+ frame := a.decFrame
299+ encPkt := a.encPkt
290300
291301 // Drain decoded frames.
292302 for {
@@ -304,6 +314,7 @@ func (a *audioTranscoder) flush(outFmtCtx unsafe.Pointer) error {
304314
305315 frameSetNbSamples(a.frame, opusFrameSize)
306316 if ret := ffmpeg.FrameGetBuffer(a.frame, 0); ret < 0 {
317+ logger.Warn().Int32("ret", ret).Msg("failed to allocate resampled frame buffer during flush")
307318 ffmpeg.FrameUnref(frame)
308319 continue
309320 }
@@ -314,6 +325,7 @@ func (a *audioTranscoder) flush(outFmtCtx unsafe.Pointer) error {
314325 unsafe.Pointer(inData), inSamples,
315326 )
316327 if converted < 0 {
328+ logger.Warn().Int32("ret", converted).Msg("audio resample failed during flush")
317329 ffmpeg.FrameUnref(frame)
318330 continue
319331 }
@@ -358,6 +370,12 @@ func (a *audioTranscoder) flush(outFmtCtx unsafe.Pointer) error {
358370
359371 // close frees all FFmpeg resources owned by the audio transcoder.
360372 func (a *audioTranscoder) close() {
373+ if a.encPkt != nil {
374+ ffmpeg.PacketFree(unsafe.Pointer(&a.encPkt))
375+ }
376+ if a.decFrame != nil {
377+ ffmpeg.FrameFree(unsafe.Pointer(&a.decFrame))
378+ }
361379 if a.frame != nil {
362380 ffmpeg.FrameFree(unsafe.Pointer(&a.frame))
363381 }
@@ -6,6 +6,7 @@ import (
6 "unsafe"6 "unsafe"
7 7
8 "github.com/bnema/purego-ffmpeg/ffmpeg"8 "github.com/bnema/purego-ffmpeg/ffmpeg"
9+ "github.com/rs/zerolog"
9 )10 )
10 11
11 // opusSampleRate is the standard output sample rate for Opus encoding.12 // opusSampleRate is the standard output sample rate for Opus encoding.
@@ -26,6 +27,8 @@ type audioTranscoder struct {
26 encoder unsafe.Pointer // AVCodecContext* for Opus encoding27 encoder unsafe.Pointer // AVCodecContext* for Opus encoding
27 resampler unsafe.Pointer // SwrContext* (nil if no resampling needed)28 resampler unsafe.Pointer // SwrContext* (nil if no resampling needed)
28 frame unsafe.Pointer // reusable AVFrame for resampled output29 frame unsafe.Pointer // reusable AVFrame for resampled output
30+ decFrame unsafe.Pointer // reusable AVFrame for decoded output
31+ encPkt unsafe.Pointer // reusable AVPacket for encoded output
29 inStream int // input stream index in the source container32 inStream int // input stream index in the source container
30 outStream int // output stream index in the muxer33 outStream int // output stream index in the muxer
31 }34 }
@@ -81,13 +84,7 @@ func newAudioTranscoder(inCodecPar, outFmtCtx unsafe.Pointer, inStreamIdx int) (
81 // Set time base to 1/sample_rate for audio.84 // Set time base to 1/sample_rate for audio.
82 ffmpeg.CodecCtxSetTimeBase(encCtx, ffmpeg.AVRational{Num: 1, Den: opusSampleRate})85 ffmpeg.CodecCtxSetTimeBase(encCtx, ffmpeg.AVRational{Num: 1, Den: opusSampleRate})
83 86
84- // If the output format requires global headers, set the flag on the encoder.87+ // Note: WebM container does not require AV_CODEC_FLAG_GLOBAL_HEADER for Opus.
85- outFlags := ffmpeg.FmtCtxFlags(outFmtCtx)
86- oformat := ffmpeg.FmtCtxOformat(outFmtCtx)
87- if oformat != nil {
88- // Check output format flags via the format context flags.
89- _ = outFlags // Format flags are on FmtCtx, not oformat.
90- }
91 88
92 if ret := ffmpeg.CodecOpen2(encCtx, enc, nil); ret < 0 {89 if ret := ffmpeg.CodecOpen2(encCtx, enc, nil); ret < 0 {
93 ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx))90 ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx))
@@ -161,11 +158,40 @@ func newAudioTranscoder(inCodecPar, outFmtCtx unsafe.Pointer, inStreamIdx int) (
161 codecCtxCopyChLayout(resampledFrame, decCtx)158 codecCtxCopyChLayout(resampledFrame, decCtx)
162 }159 }
163 160
161+ decFrame := ffmpeg.FrameAlloc()
162+ if decFrame == nil {
163+ if resampledFrame != nil {
164+ ffmpeg.FrameFree(unsafe.Pointer(&resampledFrame))
165+ }
166+ if swr != nil {
167+ ffmpeg.SwrFree(unsafe.Pointer(&swr))
168+ }
169+ ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx))
170+ ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx))
171+ return nil, errors.New("failed to allocate audio decode frame")
172+ }
173+
174+ encPkt := ffmpeg.PacketAlloc()
175+ if encPkt == nil {
176+ ffmpeg.FrameFree(unsafe.Pointer(&decFrame))
177+ if resampledFrame != nil {
178+ ffmpeg.FrameFree(unsafe.Pointer(&resampledFrame))
179+ }
180+ if swr != nil {
181+ ffmpeg.SwrFree(unsafe.Pointer(&swr))
182+ }
183+ ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx))
184+ ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx))
185+ return nil, errors.New("failed to allocate audio encode packet")
186+ }
187+
164 return &audioTranscoder{188 return &audioTranscoder{
165 decoder: decCtx,189 decoder: decCtx,
166 encoder: encCtx,190 encoder: encCtx,
167 resampler: swr,191 resampler: swr,
168 frame: resampledFrame,192 frame: resampledFrame,
193+ decFrame: decFrame,
194+ encPkt: encPkt,
169 inStream: inStreamIdx,195 inStream: inStreamIdx,
170 outStream: outStreamIdx,196 outStream: outStreamIdx,
171 }, nil197 }, nil
@@ -180,17 +206,8 @@ func (a *audioTranscoder) processPacket(pkt, outFmtCtx unsafe.Pointer) error {
180 return fmt.Errorf("audio decode send packet: %d", ret)206 return fmt.Errorf("audio decode send packet: %d", ret)
181 }207 }
182 208
183- frame := ffmpeg.FrameAlloc()209+ frame := a.decFrame
184- if frame == nil {210+ encPkt := a.encPkt
185- return errors.New("failed to allocate audio decode frame")
186- }
187- defer ffmpeg.FrameFree(unsafe.Pointer(&frame))
188-
189- encPkt := ffmpeg.PacketAlloc()
190- if encPkt == nil {
191- return errors.New("failed to allocate audio encode packet")
192- }
193- defer ffmpeg.PacketFree(unsafe.Pointer(&encPkt))
194 211
195 for {212 for {
196 ret = ffmpeg.CodecReceiveFrame(a.decoder, frame)213 ret = ffmpeg.CodecReceiveFrame(a.decoder, frame)
@@ -272,21 +289,14 @@ func (a *audioTranscoder) processPacket(pkt, outFmtCtx unsafe.Pointer) error {
272 }289 }
273 290
274 // flush drains remaining frames from the decoder and encoder.291 // flush drains remaining frames from the decoder and encoder.
275-func (a *audioTranscoder) flush(outFmtCtx unsafe.Pointer) error {292+// The logger parameter is used to report non-fatal resampling errors
293+// that occur during flush (we continue flushing rather than aborting).
294+func (a *audioTranscoder) flush(outFmtCtx unsafe.Pointer, logger zerolog.Logger) error {
276 // Flush decoder by sending NULL packet.295 // Flush decoder by sending NULL packet.
277 ffmpeg.CodecSendPacket(a.decoder, nil)296 ffmpeg.CodecSendPacket(a.decoder, nil)
278 297
279- frame := ffmpeg.FrameAlloc()298+ frame := a.decFrame
280- if frame == nil {299+ encPkt := a.encPkt
281- return errors.New("failed to allocate frame for audio flush")
282- }
283- defer ffmpeg.FrameFree(unsafe.Pointer(&frame))
284-
285- encPkt := ffmpeg.PacketAlloc()
286- if encPkt == nil {
287- return errors.New("failed to allocate packet for audio flush")
288- }
289- defer ffmpeg.PacketFree(unsafe.Pointer(&encPkt))
290 300
291 // Drain decoded frames.301 // Drain decoded frames.
292 for {302 for {
@@ -304,6 +314,7 @@ func (a *audioTranscoder) flush(outFmtCtx unsafe.Pointer) error {
304 314
305 frameSetNbSamples(a.frame, opusFrameSize)315 frameSetNbSamples(a.frame, opusFrameSize)
306 if ret := ffmpeg.FrameGetBuffer(a.frame, 0); ret < 0 {316 if ret := ffmpeg.FrameGetBuffer(a.frame, 0); ret < 0 {
317+ logger.Warn().Int32("ret", ret).Msg("failed to allocate resampled frame buffer during flush")
307 ffmpeg.FrameUnref(frame)318 ffmpeg.FrameUnref(frame)
308 continue319 continue
309 }320 }
@@ -314,6 +325,7 @@ func (a *audioTranscoder) flush(outFmtCtx unsafe.Pointer) error {
314 unsafe.Pointer(inData), inSamples,325 unsafe.Pointer(inData), inSamples,
315 )326 )
316 if converted < 0 {327 if converted < 0 {
328+ logger.Warn().Int32("ret", converted).Msg("audio resample failed during flush")
317 ffmpeg.FrameUnref(frame)329 ffmpeg.FrameUnref(frame)
318 continue330 continue
319 }331 }
@@ -358,6 +370,12 @@ func (a *audioTranscoder) flush(outFmtCtx unsafe.Pointer) error {
358 370
359 // close frees all FFmpeg resources owned by the audio transcoder.371 // close frees all FFmpeg resources owned by the audio transcoder.
360 func (a *audioTranscoder) close() {372 func (a *audioTranscoder) close() {
373+ if a.encPkt != nil {
374+ ffmpeg.PacketFree(unsafe.Pointer(&a.encPkt))
375+ }
376+ if a.decFrame != nil {
377+ ffmpeg.FrameFree(unsafe.Pointer(&a.decFrame))
378+ }
361 if a.frame != nil {379 if a.frame != nil {
362 ffmpeg.FrameFree(unsafe.Pointer(&a.frame))380 ffmpeg.FrameFree(unsafe.Pointer(&a.frame))
363 }381 }
M internal/infrastructure/transcoder/hwprobe.go +1 -3
@@ -1,8 +1,6 @@
11 package transcoder
22
33 import (
4- "unsafe"
5-
64 "github.com/bnema/purego-ffmpeg/ffmpeg"
75 "github.com/rs/zerolog"
86
@@ -90,7 +88,7 @@ func probeProfile(p hwProfile, logger *zerolog.Logger) port.HWCapabilities {
9088 }
9189 // Free the probing device context; a long-lived one will be
9290 // created per transcode session (or shared across sessions).
93- defer ffmpeg.BufferUnref(unsafe.Pointer(&deviceCtx))
91+ defer ffmpeg.BufferUnref(&deviceCtx)
9492
9593 logger.Info().
9694 Str("api", p.api).
@@ -1,8 +1,6 @@
1 package transcoder1 package transcoder
2 2
3 import (3 import (
4- "unsafe"
5-
6 "github.com/bnema/purego-ffmpeg/ffmpeg"4 "github.com/bnema/purego-ffmpeg/ffmpeg"
7 "github.com/rs/zerolog"5 "github.com/rs/zerolog"
8 6
@@ -90,7 +88,7 @@ func probeProfile(p hwProfile, logger *zerolog.Logger) port.HWCapabilities {
90 }88 }
91 // Free the probing device context; a long-lived one will be89 // Free the probing device context; a long-lived one will be
92 // created per transcode session (or shared across sessions).90 // created per transcode session (or shared across sessions).
93- defer ffmpeg.BufferUnref(unsafe.Pointer(&deviceCtx))91+ defer ffmpeg.BufferUnref(&deviceCtx)
94 92
95 logger.Info().93 logger.Info().
96 Str("api", p.api).94 Str("api", p.api).
M internal/infrastructure/transcoder/pipeline.go +28 -11
@@ -9,7 +9,11 @@ import (
99 "strings"
1010 "unsafe"
1111
12+ "net"
13+ "time"
14+
1215 "github.com/ebitengine/purego"
16+ "github.com/rs/zerolog"
1317
1418 "github.com/bnema/purego-ffmpeg/ffmpeg"
1519
@@ -31,19 +35,32 @@ type pipeline struct {
3135 quality string
3236 pw *io.PipeWriter
3337 cancel context.CancelFunc
38+ logger zerolog.Logger
3439
3540 // Prevent GC of callback closures and uintptrs.
3641 readCb uintptr
3742 writeCb uintptr
3843 }
3944
40-func newPipeline(hwCaps port.HWCapabilities, sourceURL string, headers map[string]string, quality string, pw *io.PipeWriter) *pipeline {
45+// httpClient is used for fetching source media. It has connect/TLS/header
46+// timeouts but no overall Timeout so that long-lived streaming responses
47+// are not killed. The session context handles cancellation.
48+var httpClient = &http.Client{
49+ Transport: &http.Transport{
50+ DialContext: (&net.Dialer{Timeout: 10 * time.Second}).DialContext,
51+ TLSHandshakeTimeout: 10 * time.Second,
52+ ResponseHeaderTimeout: 15 * time.Second,
53+ },
54+}
55+
56+func newPipeline(hwCaps port.HWCapabilities, sourceURL string, headers map[string]string, quality string, pw *io.PipeWriter, logger zerolog.Logger) *pipeline {
4157 return &pipeline{
4258 hwCaps: hwCaps,
4359 sourceURL: sourceURL,
4460 headers: headers,
4561 quality: quality,
4662 pw: pw,
63+ logger: logger,
4764 }
4865 }
4966
@@ -70,7 +87,7 @@ func (p *pipeline) doRun(ctx context.Context) error {
7087 req.Header.Set(k, v)
7188 }
7289
73- resp, err := http.DefaultClient.Do(req)
90+ resp, err := httpClient.Do(req)
7491 if err != nil {
7592 return fmt.Errorf("fetch source: %w", err)
7693 }
@@ -150,7 +167,7 @@ func (p *pipeline) doRun(ctx context.Context) error {
150167 return fmt.Errorf("open video encoder: %w", err)
151168 }
152169 if hwDeviceCtx != nil {
153- defer ffmpeg.BufferUnref(unsafe.Pointer(&hwDeviceCtx))
170+ defer ffmpeg.BufferUnref(&hwDeviceCtx)
154171 }
155172 defer ffmpeg.CodecFreeContext(unsafe.Pointer(&vidEncCtx))
156173
@@ -302,7 +319,7 @@ func (p *pipeline) doRun(ctx context.Context) error {
302319
303320 // Flush audio.
304321 if audioTx != nil {
305- audioTx.flush(outFmtCtx)
322+ audioTx.flush(outFmtCtx, p.logger)
306323 }
307324
308325 // --- Step 15: Write trailer ---
@@ -388,13 +405,13 @@ func (p *pipeline) openVideoEncoder(decCtx unsafe.Pointer) (hwDeviceCtx unsafe.P
388405 }
389406 }
390407 if enc == nil {
391- ffmpeg.BufferUnref(unsafe.Pointer(&hwDeviceCtx))
408+ ffmpeg.BufferUnref(&hwDeviceCtx)
392409 return nil, nil, errors.New("no usable encoder codec found")
393410 }
394411
395412 encCtx = ffmpeg.CodecAllocContext3(enc)
396413 if encCtx == nil {
397- ffmpeg.BufferUnref(unsafe.Pointer(&hwDeviceCtx))
414+ ffmpeg.BufferUnref(&hwDeviceCtx)
398415 return nil, nil, errors.New("failed to allocate video encoder context")
399416 }
400417
@@ -434,7 +451,7 @@ func (p *pipeline) openVideoEncoder(decCtx unsafe.Pointer) (hwDeviceCtx unsafe.P
434451 hwDeviceRef := ffmpeg.BufferRef(hwDeviceCtx)
435452 if hwDeviceRef == nil {
436453 ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx))
437- ffmpeg.BufferUnref(unsafe.Pointer(&hwDeviceCtx))
454+ ffmpeg.BufferUnref(&hwDeviceCtx)
438455 return nil, nil, errors.New("failed to ref HW device context for encoder")
439456 }
440457 ffmpeg.CodecCtxSetHwDeviceCtx(encCtx, hwDeviceRef)
@@ -444,7 +461,7 @@ func (p *pipeline) openVideoEncoder(decCtx unsafe.Pointer) (hwDeviceCtx unsafe.P
444461 framesRef := ffmpeg.HWFrameCtxAlloc(hwDeviceCtx)
445462 if framesRef == nil {
446463 ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx))
447- ffmpeg.BufferUnref(unsafe.Pointer(&hwDeviceCtx))
464+ ffmpeg.BufferUnref(&hwDeviceCtx)
448465 return nil, nil, errors.New("failed to allocate HW frames context")
449466 }
450467
@@ -478,9 +495,9 @@ func (p *pipeline) openVideoEncoder(decCtx unsafe.Pointer) (hwDeviceCtx unsafe.P
478495 }
479496
480497 if ret := ffmpeg.HWFrameCtxInit(framesRef); ret < 0 {
481- ffmpeg.BufferUnref(unsafe.Pointer(&framesRef))
498+ ffmpeg.BufferUnref(&framesRef)
482499 ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx))
483- ffmpeg.BufferUnref(unsafe.Pointer(&hwDeviceCtx))
500+ ffmpeg.BufferUnref(&hwDeviceCtx)
484501 return nil, nil, fmt.Errorf("init HW frames context: %d", ret)
485502 }
486503
@@ -489,7 +506,7 @@ func (p *pipeline) openVideoEncoder(decCtx unsafe.Pointer) (hwDeviceCtx unsafe.P
489506
490507 if ret := ffmpeg.CodecOpen2(encCtx, enc, nil); ret < 0 {
491508 ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx))
492- ffmpeg.BufferUnref(unsafe.Pointer(&hwDeviceCtx))
509+ ffmpeg.BufferUnref(&hwDeviceCtx)
493510 return nil, nil, fmt.Errorf("open video encoder %s: %d", encoderName, ret)
494511 }
495512
@@ -9,7 +9,11 @@ import (
9 "strings"9 "strings"
10 "unsafe"10 "unsafe"
11 11
12+ "net"
13+ "time"
14+
12 "github.com/ebitengine/purego"15 "github.com/ebitengine/purego"
16+ "github.com/rs/zerolog"
13 17
14 "github.com/bnema/purego-ffmpeg/ffmpeg"18 "github.com/bnema/purego-ffmpeg/ffmpeg"
15 19
@@ -31,19 +35,32 @@ type pipeline struct {
31 quality string35 quality string
32 pw *io.PipeWriter36 pw *io.PipeWriter
33 cancel context.CancelFunc37 cancel context.CancelFunc
38+ logger zerolog.Logger
34 39
35 // Prevent GC of callback closures and uintptrs.40 // Prevent GC of callback closures and uintptrs.
36 readCb uintptr41 readCb uintptr
37 writeCb uintptr42 writeCb uintptr
38 }43 }
39 44
40-func newPipeline(hwCaps port.HWCapabilities, sourceURL string, headers map[string]string, quality string, pw *io.PipeWriter) *pipeline {45+// httpClient is used for fetching source media. It has connect/TLS/header
46+// timeouts but no overall Timeout so that long-lived streaming responses
47+// are not killed. The session context handles cancellation.
48+var httpClient = &http.Client{
49+ Transport: &http.Transport{
50+ DialContext: (&net.Dialer{Timeout: 10 * time.Second}).DialContext,
51+ TLSHandshakeTimeout: 10 * time.Second,
52+ ResponseHeaderTimeout: 15 * time.Second,
53+ },
54+}
55+
56+func newPipeline(hwCaps port.HWCapabilities, sourceURL string, headers map[string]string, quality string, pw *io.PipeWriter, logger zerolog.Logger) *pipeline {
41 return &pipeline{57 return &pipeline{
42 hwCaps: hwCaps,58 hwCaps: hwCaps,
43 sourceURL: sourceURL,59 sourceURL: sourceURL,
44 headers: headers,60 headers: headers,
45 quality: quality,61 quality: quality,
46 pw: pw,62 pw: pw,
63+ logger: logger,
47 }64 }
48 }65 }
49 66
@@ -70,7 +87,7 @@ func (p *pipeline) doRun(ctx context.Context) error {
70 req.Header.Set(k, v)87 req.Header.Set(k, v)
71 }88 }
72 89
73- resp, err := http.DefaultClient.Do(req)90+ resp, err := httpClient.Do(req)
74 if err != nil {91 if err != nil {
75 return fmt.Errorf("fetch source: %w", err)92 return fmt.Errorf("fetch source: %w", err)
76 }93 }
@@ -150,7 +167,7 @@ func (p *pipeline) doRun(ctx context.Context) error {
150 return fmt.Errorf("open video encoder: %w", err)167 return fmt.Errorf("open video encoder: %w", err)
151 }168 }
152 if hwDeviceCtx != nil {169 if hwDeviceCtx != nil {
153- defer ffmpeg.BufferUnref(unsafe.Pointer(&hwDeviceCtx))170+ defer ffmpeg.BufferUnref(&hwDeviceCtx)
154 }171 }
155 defer ffmpeg.CodecFreeContext(unsafe.Pointer(&vidEncCtx))172 defer ffmpeg.CodecFreeContext(unsafe.Pointer(&vidEncCtx))
156 173
@@ -302,7 +319,7 @@ func (p *pipeline) doRun(ctx context.Context) error {
302 319
303 // Flush audio.320 // Flush audio.
304 if audioTx != nil {321 if audioTx != nil {
305- audioTx.flush(outFmtCtx)322+ audioTx.flush(outFmtCtx, p.logger)
306 }323 }
307 324
308 // --- Step 15: Write trailer ---325 // --- Step 15: Write trailer ---
@@ -388,13 +405,13 @@ func (p *pipeline) openVideoEncoder(decCtx unsafe.Pointer) (hwDeviceCtx unsafe.P
388 }405 }
389 }406 }
390 if enc == nil {407 if enc == nil {
391- ffmpeg.BufferUnref(unsafe.Pointer(&hwDeviceCtx))408+ ffmpeg.BufferUnref(&hwDeviceCtx)
392 return nil, nil, errors.New("no usable encoder codec found")409 return nil, nil, errors.New("no usable encoder codec found")
393 }410 }
394 411
395 encCtx = ffmpeg.CodecAllocContext3(enc)412 encCtx = ffmpeg.CodecAllocContext3(enc)
396 if encCtx == nil {413 if encCtx == nil {
397- ffmpeg.BufferUnref(unsafe.Pointer(&hwDeviceCtx))414+ ffmpeg.BufferUnref(&hwDeviceCtx)
398 return nil, nil, errors.New("failed to allocate video encoder context")415 return nil, nil, errors.New("failed to allocate video encoder context")
399 }416 }
400 417
@@ -434,7 +451,7 @@ func (p *pipeline) openVideoEncoder(decCtx unsafe.Pointer) (hwDeviceCtx unsafe.P
434 hwDeviceRef := ffmpeg.BufferRef(hwDeviceCtx)451 hwDeviceRef := ffmpeg.BufferRef(hwDeviceCtx)
435 if hwDeviceRef == nil {452 if hwDeviceRef == nil {
436 ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx))453 ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx))
437- ffmpeg.BufferUnref(unsafe.Pointer(&hwDeviceCtx))454+ ffmpeg.BufferUnref(&hwDeviceCtx)
438 return nil, nil, errors.New("failed to ref HW device context for encoder")455 return nil, nil, errors.New("failed to ref HW device context for encoder")
439 }456 }
440 ffmpeg.CodecCtxSetHwDeviceCtx(encCtx, hwDeviceRef)457 ffmpeg.CodecCtxSetHwDeviceCtx(encCtx, hwDeviceRef)
@@ -444,7 +461,7 @@ func (p *pipeline) openVideoEncoder(decCtx unsafe.Pointer) (hwDeviceCtx unsafe.P
444 framesRef := ffmpeg.HWFrameCtxAlloc(hwDeviceCtx)461 framesRef := ffmpeg.HWFrameCtxAlloc(hwDeviceCtx)
445 if framesRef == nil {462 if framesRef == nil {
446 ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx))463 ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx))
447- ffmpeg.BufferUnref(unsafe.Pointer(&hwDeviceCtx))464+ ffmpeg.BufferUnref(&hwDeviceCtx)
448 return nil, nil, errors.New("failed to allocate HW frames context")465 return nil, nil, errors.New("failed to allocate HW frames context")
449 }466 }
450 467
@@ -478,9 +495,9 @@ func (p *pipeline) openVideoEncoder(decCtx unsafe.Pointer) (hwDeviceCtx unsafe.P
478 }495 }
479 496
480 if ret := ffmpeg.HWFrameCtxInit(framesRef); ret < 0 {497 if ret := ffmpeg.HWFrameCtxInit(framesRef); ret < 0 {
481- ffmpeg.BufferUnref(unsafe.Pointer(&framesRef))498+ ffmpeg.BufferUnref(&framesRef)
482 ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx))499 ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx))
483- ffmpeg.BufferUnref(unsafe.Pointer(&hwDeviceCtx))500+ ffmpeg.BufferUnref(&hwDeviceCtx)
484 return nil, nil, fmt.Errorf("init HW frames context: %d", ret)501 return nil, nil, fmt.Errorf("init HW frames context: %d", ret)
485 }502 }
486 503
@@ -489,7 +506,7 @@ func (p *pipeline) openVideoEncoder(decCtx unsafe.Pointer) (hwDeviceCtx unsafe.P
489 506
490 if ret := ffmpeg.CodecOpen2(encCtx, enc, nil); ret < 0 {507 if ret := ffmpeg.CodecOpen2(encCtx, enc, nil); ret < 0 {
491 ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx))508 ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx))
492- ffmpeg.BufferUnref(unsafe.Pointer(&hwDeviceCtx))509+ ffmpeg.BufferUnref(&hwDeviceCtx)
493 return nil, nil, fmt.Errorf("open video encoder %s: %d", encoderName, ret)510 return nil, nil, fmt.Errorf("open video encoder %s: %d", encoderName, ret)
494 }511 }
495 512
M internal/infrastructure/transcoder/pool.go +3 -3
@@ -49,7 +49,7 @@ var errPoolAtCapacity = errors.New("transcoder: session pool at capacity")
4949
5050 // sessionPool tracks active transcode sessions with a concurrency limit.
5151 type sessionPool struct {
52- mu sync.Mutex
52+ mu sync.RWMutex
5353 sessions map[string]*session
5454 maxConc int
5555 }
@@ -83,8 +83,8 @@ func (p *sessionPool) remove(id string) {
8383
8484 // count returns the number of active sessions.
8585 func (p *sessionPool) count() int {
86- p.mu.Lock()
87- defer p.mu.Unlock()
86+ p.mu.RLock()
87+ defer p.mu.RUnlock()
8888 return len(p.sessions)
8989 }
9090
@@ -49,7 +49,7 @@ var errPoolAtCapacity = errors.New("transcoder: session pool at capacity")
49 49
50 // sessionPool tracks active transcode sessions with a concurrency limit.50 // sessionPool tracks active transcode sessions with a concurrency limit.
51 type sessionPool struct {51 type sessionPool struct {
52- mu sync.Mutex52+ mu sync.RWMutex
53 sessions map[string]*session53 sessions map[string]*session
54 maxConc int54 maxConc int
55 }55 }
@@ -83,8 +83,8 @@ func (p *sessionPool) remove(id string) {
83 83
84 // count returns the number of active sessions.84 // count returns the number of active sessions.
85 func (p *sessionPool) count() int {85 func (p *sessionPool) count() int {
86- p.mu.Lock()86+ p.mu.RLock()
87- defer p.mu.Unlock()87+ defer p.mu.RUnlock()
88 return len(p.sessions)88 return len(p.sessions)
89 }89 }
90 90
M internal/infrastructure/transcoder/transcoder.go +10 -5
@@ -111,7 +111,7 @@ func (t *Transcoder) Start(ctx context.Context, sourceURL string, headers map[st
111111 return nil, fmt.Errorf("transcoder: %w", err)
112112 }
113113
114- p := newPipeline(t.hwCaps, sourceURL, headers, t.cfg.Quality, pw)
114+ p := newPipeline(t.hwCaps, sourceURL, headers, t.cfg.Quality, pw, t.logger)
115115
116116 t.logger.Info().
117117 Str("session_id", id).
@@ -120,11 +120,16 @@ func (t *Transcoder) Start(ctx context.Context, sourceURL string, headers map[st
120120 Msg("starting transcode session")
121121
122122 go func() {
123+ defer func() {
124+ t.pool.remove(id)
125+ t.logger.Info().Str("session_id", id).Msg("transcode session finished")
126+ }()
127+ defer func() {
128+ if r := recover(); r != nil {
129+ t.logger.Error().Str("session_id", id).Interface("panic", r).Msg("transcode session panicked")
130+ }
131+ }()
123132 p.run(sessionCtx)
124- t.pool.remove(id)
125- t.logger.Info().
126- Str("session_id", id).
127- Msg("transcode session finished")
128133 }()
129134
130135 return s, nil
@@ -111,7 +111,7 @@ func (t *Transcoder) Start(ctx context.Context, sourceURL string, headers map[st
111 return nil, fmt.Errorf("transcoder: %w", err)111 return nil, fmt.Errorf("transcoder: %w", err)
112 }112 }
113 113
114- p := newPipeline(t.hwCaps, sourceURL, headers, t.cfg.Quality, pw)114+ p := newPipeline(t.hwCaps, sourceURL, headers, t.cfg.Quality, pw, t.logger)
115 115
116 t.logger.Info().116 t.logger.Info().
117 Str("session_id", id).117 Str("session_id", id).
@@ -120,11 +120,16 @@ func (t *Transcoder) Start(ctx context.Context, sourceURL string, headers map[st
120 Msg("starting transcode session")120 Msg("starting transcode session")
121 121
122 go func() {122 go func() {
123+ defer func() {
124+ t.pool.remove(id)
125+ t.logger.Info().Str("session_id", id).Msg("transcode session finished")
126+ }()
127+ defer func() {
128+ if r := recover(); r != nil {
129+ t.logger.Error().Str("session_id", id).Interface("panic", r).Msg("transcode session panicked")
130+ }
131+ }()
123 p.run(sessionCtx)132 p.run(sessionCtx)
124- t.pool.remove(id)
125- t.logger.Info().
126- Str("session_id", id).
127- Msg("transcode session finished")
128 }()133 }()
129 134
130 return s, nil135 return s, nil

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close