detached 052bd05

feat: GPU media proxy transcoding (VAAPI/NVENC)

Automatically intercept and transcode incompatible video streams (H.264/AAC)
to open codecs (VP9/AV1 + Opus) using GPU hardware encoding.

Architecture:
- Automatic codec detection via Content-Type in OnResourceResponse
  (no domain whitelist — catches any site serving proprietary codecs)
- GPU-only transcoding: VAAPI (AMD/Intel) or NVENC (NVIDIA)
- No software fallback — if no compatible GPU, feature is disabled
- Zero-copy GPU pipeline when decode+encode on same device

New packages:
- internal/application/port/transcoder.go — MediaTranscoder interface
- internal/infrastructure/transcoder/ — FFmpeg GPU pipeline, audio
  transcoding (AAC→Opus), hardware probe, session pool, codec detection

CEF integration:
- transcoding_handler.go — ResourceRequestHandler + ResourceHandler
- OnResourceResponse auto-detects video/mp4 → restarts through transcoder
- Streams transcoded WebM via ResourceHandler.Read() callbacks

Config: media.transcoding.enabled, hwaccel, max_concurrent, quality

Dependencies: purego-ffmpeg v0.1.0 (custom AVIO, hwaccel, struct accessors)
commit
052bd054570172c068600c3d5861ab943f1122b6
author
brice · · 2026-03-24 06:49 UTC
committer
brice · · 2026-03-28 04:39 UTC
parent
7be6b41
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
16 files · +1995 -1
M go.mod +4 -1
@@ -4,6 +4,7 @@ go 1.26.1
44
55 require (
66 github.com/bnema/purego-cef v0.0.0-00010101000000-000000000000
7+ github.com/bnema/purego-ffmpeg v0.1.0
78 github.com/bnema/purego-sqlite v0.1.1
89 github.com/bnema/puregotk v0.2.2
910 github.com/bnema/puregotk-webkit v0.0.16
@@ -13,6 +14,7 @@ require (
1314 github.com/ebitengine/purego v0.10.0
1415 github.com/fsnotify/fsnotify v1.9.0
1516 github.com/godbus/dbus/v5 v5.2.2
17+ github.com/google/uuid v1.6.0
1618 github.com/pelletier/go-toml/v2 v2.3.0
1719 github.com/pressly/goose/v3 v3.27.0
1820 github.com/rs/zerolog v1.34.0
@@ -29,6 +31,8 @@ replace github.com/ebitengine/purego => github.com/bnema/purego v0.0.0-202603230
2931
3032 replace github.com/bnema/purego-cef => ../purego-cef
3133
34+replace github.com/bnema/purego-ffmpeg => ../purego-ffmpeg
35+
3236 require (
3337 github.com/atotto/clipboard v0.1.4 // indirect
3438 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
@@ -44,7 +48,6 @@ require (
4448 github.com/dustin/go-humanize v1.0.1 // indirect
4549 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
4650 github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
47- github.com/google/uuid v1.6.0 // indirect
4851 github.com/inconshreveable/mousetrap v1.1.0 // indirect
4952 github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
5053 github.com/mattn/go-colorable v0.1.14 // indirect
@@ -4,6 +4,7 @@ go 1.26.1
4 4
5 require (5 require (
6 github.com/bnema/purego-cef v0.0.0-00010101000000-0000000000006 github.com/bnema/purego-cef v0.0.0-00010101000000-000000000000
7+ github.com/bnema/purego-ffmpeg v0.1.0
7 github.com/bnema/purego-sqlite v0.1.18 github.com/bnema/purego-sqlite v0.1.1
8 github.com/bnema/puregotk v0.2.29 github.com/bnema/puregotk v0.2.2
9 github.com/bnema/puregotk-webkit v0.0.1610 github.com/bnema/puregotk-webkit v0.0.16
@@ -13,6 +14,7 @@ require (
13 github.com/ebitengine/purego v0.10.014 github.com/ebitengine/purego v0.10.0
14 github.com/fsnotify/fsnotify v1.9.015 github.com/fsnotify/fsnotify v1.9.0
15 github.com/godbus/dbus/v5 v5.2.216 github.com/godbus/dbus/v5 v5.2.2
17+ github.com/google/uuid v1.6.0
16 github.com/pelletier/go-toml/v2 v2.3.018 github.com/pelletier/go-toml/v2 v2.3.0
17 github.com/pressly/goose/v3 v3.27.019 github.com/pressly/goose/v3 v3.27.0
18 github.com/rs/zerolog v1.34.020 github.com/rs/zerolog v1.34.0
@@ -29,6 +31,8 @@ replace github.com/ebitengine/purego => github.com/bnema/purego v0.0.0-202603230
29 31
30 replace github.com/bnema/purego-cef => ../purego-cef32 replace github.com/bnema/purego-cef => ../purego-cef
31 33
34+replace github.com/bnema/purego-ffmpeg => ../purego-ffmpeg
35+
32 require (36 require (
33 github.com/atotto/clipboard v0.1.4 // indirect37 github.com/atotto/clipboard v0.1.4 // indirect
34 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect38 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
@@ -44,7 +48,6 @@ require (
44 github.com/dustin/go-humanize v1.0.1 // indirect48 github.com/dustin/go-humanize v1.0.1 // indirect
45 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect49 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
46 github.com/go-viper/mapstructure/v2 v2.5.0 // indirect50 github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
47- github.com/google/uuid v1.6.0 // indirect
48 github.com/inconshreveable/mousetrap v1.1.0 // indirect51 github.com/inconshreveable/mousetrap v1.1.0 // indirect
49 github.com/lucasb-eyer/go-colorful v1.3.0 // indirect52 github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
50 github.com/mattn/go-colorable v0.1.14 // indirect53 github.com/mattn/go-colorable v0.1.14 // indirect
A internal/application/port/transcoder.go +33 -0
@@ -0,0 +1,33 @@
1+package port
2+
3+import (
4+ "context"
5+ "io"
6+)
7+
8+// TranscodeSession represents an active GPU transcoding session.
9+// Read returns transcoded chunks. Close aborts and frees GPU resources.
10+type TranscodeSession interface {
11+ io.ReadCloser
12+ // ContentType returns the MIME type of the transcoded output (e.g., "video/webm").
13+ ContentType() string
14+}
15+
16+// HWCapabilities describes the detected GPU hardware encoding capabilities.
17+type HWCapabilities struct {
18+ API string // "vaapi", "nvenc", or "" (none available)
19+ Device string // auto-detected device path
20+ Encoders []string // e.g., ["av1_vaapi"]
21+ Decoders []string // e.g., ["h264_vaapi"]
22+}
23+
24+// MediaTranscoder handles GPU-accelerated video transcoding.
25+type MediaTranscoder interface {
26+ // Available returns true if a compatible GPU encoder was found.
27+ Available() bool
28+ // Capabilities returns the detected GPU hardware info.
29+ Capabilities() HWCapabilities
30+ // Start begins a GPU transcode session, fetching from sourceURL.
31+ // Headers are forwarded to the source HTTP request (cookies, auth).
32+ Start(ctx context.Context, sourceURL string, headers map[string]string) (TranscodeSession, error)
33+}
@@ -0,0 +1,33 @@
1+package port
2+
3+import (
4+ "context"
5+ "io"
6+)
7+
8+// TranscodeSession represents an active GPU transcoding session.
9+// Read returns transcoded chunks. Close aborts and frees GPU resources.
10+type TranscodeSession interface {
11+ io.ReadCloser
12+ // ContentType returns the MIME type of the transcoded output (e.g., "video/webm").
13+ ContentType() string
14+}
15+
16+// HWCapabilities describes the detected GPU hardware encoding capabilities.
17+type HWCapabilities struct {
18+ API string // "vaapi", "nvenc", or "" (none available)
19+ Device string // auto-detected device path
20+ Encoders []string // e.g., ["av1_vaapi"]
21+ Decoders []string // e.g., ["h264_vaapi"]
22+}
23+
24+// MediaTranscoder handles GPU-accelerated video transcoding.
25+type MediaTranscoder interface {
26+ // Available returns true if a compatible GPU encoder was found.
27+ Available() bool
28+ // Capabilities returns the detected GPU hardware info.
29+ Capabilities() HWCapabilities
30+ // Start begins a GPU transcode session, fetching from sourceURL.
31+ // Headers are forwarded to the source HTTP request (cookies, auth).
32+ Start(ctx context.Context, sourceURL string, headers map[string]string) (TranscodeSession, error)
33+}
M internal/infrastructure/cef/engine_init.go +18 -0
@@ -14,7 +14,9 @@ import (
1414 "github.com/rs/zerolog"
1515
1616 "github.com/bnema/dumber/assets"
17+ "github.com/bnema/dumber/internal/application/port"
1718 "github.com/bnema/dumber/internal/infrastructure/config"
19+ transcoderpkg "github.com/bnema/dumber/internal/infrastructure/transcoder"
1820 "github.com/bnema/dumber/internal/logging"
1921 )
2022
@@ -138,10 +140,26 @@ func wireEngine(
138140 }
139141 }
140142
143+ // Initialize GPU transcoder if enabled in the global config.
144+ var mediaTranscoder port.MediaTranscoder
145+ if appCfg := config.Get(); appCfg != nil && appCfg.Transcoding.Enabled {
146+ tc := transcoderpkg.New(appCfg.Transcoding, logger)
147+ if tc.Available() {
148+ mediaTranscoder = tc
149+ logger.Info().
150+ Str("api", tc.Capabilities().API).
151+ Strs("encoders", tc.Capabilities().Encoders).
152+ Msg("cef: GPU transcoding available")
153+ } else {
154+ logger.Warn().Msg("cef: GPU transcoding enabled but no compatible GPU found — feature disabled")
155+ }
156+ }
157+
141158 factory := newWebViewFactory(eng, gl, webViewFactoryOptions{
142159 scale: scale,
143160 windowlessFrameRate: windowlessFrameRate,
144161 enableContextMenuHandler: cfg.EnableContextMenuHandler,
162+ transcoder: mediaTranscoder,
145163 })
146164 pool := newWebViewPool(factory)
147165
@@ -14,7 +14,9 @@ import (
14 "github.com/rs/zerolog"14 "github.com/rs/zerolog"
15 15
16 "github.com/bnema/dumber/assets"16 "github.com/bnema/dumber/assets"
17+ "github.com/bnema/dumber/internal/application/port"
17 "github.com/bnema/dumber/internal/infrastructure/config"18 "github.com/bnema/dumber/internal/infrastructure/config"
19+ transcoderpkg "github.com/bnema/dumber/internal/infrastructure/transcoder"
18 "github.com/bnema/dumber/internal/logging"20 "github.com/bnema/dumber/internal/logging"
19 )21 )
20 22
@@ -138,10 +140,26 @@ func wireEngine(
138 }140 }
139 }141 }
140 142
143+ // Initialize GPU transcoder if enabled in the global config.
144+ var mediaTranscoder port.MediaTranscoder
145+ if appCfg := config.Get(); appCfg != nil && appCfg.Transcoding.Enabled {
146+ tc := transcoderpkg.New(appCfg.Transcoding, logger)
147+ if tc.Available() {
148+ mediaTranscoder = tc
149+ logger.Info().
150+ Str("api", tc.Capabilities().API).
151+ Strs("encoders", tc.Capabilities().Encoders).
152+ Msg("cef: GPU transcoding available")
153+ } else {
154+ logger.Warn().Msg("cef: GPU transcoding enabled but no compatible GPU found — feature disabled")
155+ }
156+ }
157+
141 factory := newWebViewFactory(eng, gl, webViewFactoryOptions{158 factory := newWebViewFactory(eng, gl, webViewFactoryOptions{
142 scale: scale,159 scale: scale,
143 windowlessFrameRate: windowlessFrameRate,160 windowlessFrameRate: windowlessFrameRate,
144 enableContextMenuHandler: cfg.EnableContextMenuHandler,161 enableContextMenuHandler: cfg.EnableContextMenuHandler,
162+ transcoder: mediaTranscoder,
145 })163 })
146 pool := newWebViewPool(factory)164 pool := newWebViewPool(factory)
147 165
M internal/infrastructure/cef/factory.go +9 -0
@@ -24,12 +24,14 @@ type WebViewFactory struct {
2424 windowlessFrameRate int32
2525 enableContextMenuHandler bool
2626 bgColor atomic.Uint32 // packed ARGB for BrowserSettings.BackgroundColor
27+ transcoder port.MediaTranscoder
2728 }
2829
2930 type webViewFactoryOptions struct {
3031 scale int32
3132 windowlessFrameRate int32
3233 enableContextMenuHandler bool
34+ transcoder port.MediaTranscoder
3335 }
3436
3537 // newWebViewFactory returns a factory that will create WebViews using the
@@ -47,6 +49,7 @@ func newWebViewFactory(engine *Engine, gl *glLoader, opts webViewFactoryOptions)
4749 scale: opts.scale,
4850 windowlessFrameRate: opts.windowlessFrameRate,
4951 enableContextMenuHandler: opts.enableContextMenuHandler,
52+ transcoder: opts.transcoder,
5053 }
5154 }
5255
@@ -72,9 +75,15 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) {
7275 pipeline: pipeline,
7376 }
7477
78+ var transcodingHandler purecef.ResourceRequestHandler
79+ if f.transcoder != nil {
80+ transcodingHandler = newTranscodingRequestHandler(f.transcoder)
81+ }
82+
7583 handlers := &handlerSet{
7684 wv: wv,
7785 enableContextMenuHandler: f.enableContextMenuHandler,
86+ transcodingHandler: transcodingHandler,
7887 }
7988 wv.handlers = handlers
8089 wv.findCtrl = newFindController()
@@ -24,12 +24,14 @@ type WebViewFactory struct {
24 windowlessFrameRate int3224 windowlessFrameRate int32
25 enableContextMenuHandler bool25 enableContextMenuHandler bool
26 bgColor atomic.Uint32 // packed ARGB for BrowserSettings.BackgroundColor26 bgColor atomic.Uint32 // packed ARGB for BrowserSettings.BackgroundColor
27+ transcoder port.MediaTranscoder
27 }28 }
28 29
29 type webViewFactoryOptions struct {30 type webViewFactoryOptions struct {
30 scale int3231 scale int32
31 windowlessFrameRate int3232 windowlessFrameRate int32
32 enableContextMenuHandler bool33 enableContextMenuHandler bool
34+ transcoder port.MediaTranscoder
33 }35 }
34 36
35 // newWebViewFactory returns a factory that will create WebViews using the37 // newWebViewFactory returns a factory that will create WebViews using the
@@ -47,6 +49,7 @@ func newWebViewFactory(engine *Engine, gl *glLoader, opts webViewFactoryOptions)
47 scale: opts.scale,49 scale: opts.scale,
48 windowlessFrameRate: opts.windowlessFrameRate,50 windowlessFrameRate: opts.windowlessFrameRate,
49 enableContextMenuHandler: opts.enableContextMenuHandler,51 enableContextMenuHandler: opts.enableContextMenuHandler,
52+ transcoder: opts.transcoder,
50 }53 }
51 }54 }
52 55
@@ -72,9 +75,15 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) {
72 pipeline: pipeline,75 pipeline: pipeline,
73 }76 }
74 77
78+ var transcodingHandler purecef.ResourceRequestHandler
79+ if f.transcoder != nil {
80+ transcodingHandler = newTranscodingRequestHandler(f.transcoder)
81+ }
82+
75 handlers := &handlerSet{83 handlers := &handlerSet{
76 wv: wv,84 wv: wv,
77 enableContextMenuHandler: f.enableContextMenuHandler,85 enableContextMenuHandler: f.enableContextMenuHandler,
86+ transcodingHandler: transcodingHandler,
78 }87 }
79 wv.handlers = handlers88 wv.handlers = handlers
80 wv.findCtrl = newFindController()89 wv.findCtrl = newFindController()
A internal/infrastructure/cef/transcoding_handler.go +213 -0
@@ -0,0 +1,213 @@
1+package cef
2+
3+import (
4+ "context"
5+ "sync"
6+ "unsafe"
7+
8+ purecef "github.com/bnema/purego-cef/cef"
9+
10+ "github.com/bnema/dumber/internal/application/port"
11+ "github.com/bnema/dumber/internal/infrastructure/transcoder"
12+)
13+
14+// ---------------------------------------------------------------------------
15+// Compile-time interface checks
16+// ---------------------------------------------------------------------------
17+
18+var _ purecef.ResourceRequestHandler = (*transcodingRequestHandler)(nil)
19+
20+// transcodingResourceHandler is checked indirectly: purecef.NewResourceHandler
21+// accepts a purecef.ResourceHandler, and the struct implements all methods.
22+
23+// ---------------------------------------------------------------------------
24+// transcodingResourceHandler — serves a transcoded WebM stream to CEF
25+// ---------------------------------------------------------------------------
26+
27+type transcodingResourceHandler struct {
28+ transcoder port.MediaTranscoder
29+ sourceURL string
30+ headers map[string]string
31+ session port.TranscodeSession
32+ ctx context.Context
33+ cancel context.CancelFunc
34+}
35+
36+// Open starts the transcode session asynchronously. CEF will call
37+// GetResponseHeaders and Read once the callback fires.
38+func (rh *transcodingResourceHandler) Open(_ purecef.Request, handleRequest unsafe.Pointer, callback purecef.Callback) int32 {
39+ // Signal async handling: set handleRequest = 0.
40+ *(*int32)(handleRequest) = 0
41+
42+ go func() {
43+ session, err := rh.transcoder.Start(rh.ctx, rh.sourceURL, rh.headers)
44+ if err != nil {
45+ // Let CEF know we failed — Cancel will be called.
46+ rh.cancel()
47+ return
48+ }
49+ rh.session = session
50+ callback.Cont()
51+ }()
52+
53+ return 1
54+}
55+
56+// GetResponseHeaders sets the streaming response metadata.
57+func (rh *transcodingResourceHandler) GetResponseHeaders(response purecef.Response, responseLength unsafe.Pointer, _ uintptr) {
58+ response.SetStatus(200)
59+ response.SetStatusText("OK")
60+ response.SetMimeType("video/webm")
61+ response.SetHeaderByName("Accept-Ranges", "none", 1)
62+ // Streaming — unknown length.
63+ *(*int64)(responseLength) = -1
64+}
65+
66+// Read copies transcoded data from the session into the CEF output buffer.
67+func (rh *transcodingResourceHandler) Read(
68+ dataOut unsafe.Pointer, bytesToRead int32,
69+ bytesRead unsafe.Pointer, _ purecef.ResourceReadCallback,
70+) int32 {
71+ if rh.session == nil {
72+ return 0
73+ }
74+
75+ buf := make([]byte, bytesToRead)
76+ n, err := rh.session.Read(buf)
77+ if n > 0 {
78+ dst := unsafe.Slice((*byte)(dataOut), n)
79+ copy(dst, buf[:n])
80+ *(*int32)(bytesRead) = int32(n)
81+ return 1
82+ }
83+ if err != nil {
84+ return 0 // EOF or error
85+ }
86+ return 0
87+}
88+
89+// Cancel aborts the transcode session and releases resources.
90+func (rh *transcodingResourceHandler) Cancel() {
91+ if rh.session != nil {
92+ rh.session.Close()
93+ }
94+ if rh.cancel != nil {
95+ rh.cancel()
96+ }
97+}
98+
99+// ProcessRequest is deprecated; Open is used instead.
100+func (rh *transcodingResourceHandler) ProcessRequest(_ purecef.Request, _ purecef.Callback) int32 {
101+ return 0
102+}
103+
104+// ReadResponse is deprecated; Read is used instead.
105+func (rh *transcodingResourceHandler) ReadResponse(_ unsafe.Pointer, _ int32, _ unsafe.Pointer, _ purecef.Callback) int32 {
106+ return 0
107+}
108+
109+// Skip is not used for streaming content.
110+func (rh *transcodingResourceHandler) Skip(_ int64, _ unsafe.Pointer, _ purecef.ResourceSkipCallback) int32 {
111+ return 0
112+}
113+
114+// ---------------------------------------------------------------------------
115+// transcodingRequestHandler — ResourceRequestHandler that intercepts
116+// proprietary video responses and restarts them through the transcoder
117+// ---------------------------------------------------------------------------
118+
119+type transcodingRequestHandler struct {
120+ transcoder port.MediaTranscoder
121+ transcodableURLs sync.Map // url string -> requestInfo
122+}
123+
124+type requestInfo struct {
125+ headers map[string]string
126+}
127+
128+// newTranscodingRequestHandler creates a ResourceRequestHandler that detects
129+// proprietary video MIME types and restarts those requests through a GPU
130+// transcoding ResourceHandler.
131+func newTranscodingRequestHandler(tc port.MediaTranscoder) purecef.ResourceRequestHandler {
132+ return purecef.NewResourceRequestHandler(&transcodingRequestHandler{
133+ transcoder: tc,
134+ })
135+}
136+
137+// OnResourceResponse inspects the MIME type of every response. If it matches
138+// a proprietary video format (H.264, HEVC, etc.), it stores the request info
139+// and returns 1 to restart the request — at which point GetResourceHandler
140+// will provide the transcoding handler.
141+func (h *transcodingRequestHandler) OnResourceResponse(_ purecef.Browser, _ purecef.Frame, request purecef.Request, response purecef.Response) int32 {
142+ if response == nil {
143+ return 0
144+ }
145+ mimeType := response.GetMimeType()
146+ if !transcoder.IsProprietaryVideoMIME(mimeType) {
147+ return 0
148+ }
149+
150+ // Extract relevant headers from the original request so the transcoder
151+ // can authenticate with the source server.
152+ headers := make(map[string]string)
153+ if cookie := request.GetHeaderByName("Cookie"); cookie != "" {
154+ headers["Cookie"] = cookie
155+ }
156+ if auth := request.GetHeaderByName("Authorization"); auth != "" {
157+ headers["Authorization"] = auth
158+ }
159+ if referer := request.GetHeaderByName("Referer"); referer != "" {
160+ headers["Referer"] = referer
161+ }
162+
163+ url := request.GetURL()
164+ h.transcodableURLs.Store(url, requestInfo{headers: headers})
165+
166+ return 1 // restart request
167+}
168+
169+// GetResourceHandler returns a transcoding ResourceHandler for URLs that were
170+// previously identified as proprietary video by OnResourceResponse.
171+func (h *transcodingRequestHandler) GetResourceHandler(_ purecef.Browser, _ purecef.Frame, request purecef.Request) purecef.ResourceHandler {
172+ if request == nil {
173+ return nil
174+ }
175+ url := request.GetURL()
176+ val, ok := h.transcodableURLs.LoadAndDelete(url)
177+ if !ok {
178+ return nil
179+ }
180+ info := val.(requestInfo)
181+
182+ ctx, cancel := context.WithCancel(context.Background())
183+ return purecef.NewResourceHandler(&transcodingResourceHandler{
184+ transcoder: h.transcoder,
185+ sourceURL: url,
186+ headers: info.headers,
187+ ctx: ctx,
188+ cancel: cancel,
189+ })
190+}
191+
192+// --- No-op methods required by purecef.ResourceRequestHandler ---
193+
194+func (h *transcodingRequestHandler) GetCookieAccessFilter(_ purecef.Browser, _ purecef.Frame, _ purecef.Request) purecef.CookieAccessFilter {
195+ return nil
196+}
197+
198+func (h *transcodingRequestHandler) OnBeforeResourceLoad(_ purecef.Browser, _ purecef.Frame, _ purecef.Request, _ purecef.Callback) purecef.ReturnValue {
199+ return purecef.ReturnValueRvContinue
200+}
201+
202+func (h *transcodingRequestHandler) OnResourceRedirect(_ purecef.Browser, _ purecef.Frame, _ purecef.Request, _ purecef.Response, _ uintptr) {
203+}
204+
205+func (h *transcodingRequestHandler) GetResourceResponseFilter(_ purecef.Browser, _ purecef.Frame, _ purecef.Request, _ purecef.Response) purecef.ResponseFilter {
206+ return nil
207+}
208+
209+func (h *transcodingRequestHandler) OnResourceLoadComplete(_ purecef.Browser, _ purecef.Frame, _ purecef.Request, _ purecef.Response, _ purecef.UrlrequestStatus, _ int64) {
210+}
211+
212+func (h *transcodingRequestHandler) OnProtocolExecution(_ purecef.Browser, _ purecef.Frame, _ purecef.Request, _ unsafe.Pointer) {
213+}
@@ -0,0 +1,213 @@
1+package cef
2+
3+import (
4+ "context"
5+ "sync"
6+ "unsafe"
7+
8+ purecef "github.com/bnema/purego-cef/cef"
9+
10+ "github.com/bnema/dumber/internal/application/port"
11+ "github.com/bnema/dumber/internal/infrastructure/transcoder"
12+)
13+
14+// ---------------------------------------------------------------------------
15+// Compile-time interface checks
16+// ---------------------------------------------------------------------------
17+
18+var _ purecef.ResourceRequestHandler = (*transcodingRequestHandler)(nil)
19+
20+// transcodingResourceHandler is checked indirectly: purecef.NewResourceHandler
21+// accepts a purecef.ResourceHandler, and the struct implements all methods.
22+
23+// ---------------------------------------------------------------------------
24+// transcodingResourceHandler — serves a transcoded WebM stream to CEF
25+// ---------------------------------------------------------------------------
26+
27+type transcodingResourceHandler struct {
28+ transcoder port.MediaTranscoder
29+ sourceURL string
30+ headers map[string]string
31+ session port.TranscodeSession
32+ ctx context.Context
33+ cancel context.CancelFunc
34+}
35+
36+// Open starts the transcode session asynchronously. CEF will call
37+// GetResponseHeaders and Read once the callback fires.
38+func (rh *transcodingResourceHandler) Open(_ purecef.Request, handleRequest unsafe.Pointer, callback purecef.Callback) int32 {
39+ // Signal async handling: set handleRequest = 0.
40+ *(*int32)(handleRequest) = 0
41+
42+ go func() {
43+ session, err := rh.transcoder.Start(rh.ctx, rh.sourceURL, rh.headers)
44+ if err != nil {
45+ // Let CEF know we failed — Cancel will be called.
46+ rh.cancel()
47+ return
48+ }
49+ rh.session = session
50+ callback.Cont()
51+ }()
52+
53+ return 1
54+}
55+
56+// GetResponseHeaders sets the streaming response metadata.
57+func (rh *transcodingResourceHandler) GetResponseHeaders(response purecef.Response, responseLength unsafe.Pointer, _ uintptr) {
58+ response.SetStatus(200)
59+ response.SetStatusText("OK")
60+ response.SetMimeType("video/webm")
61+ response.SetHeaderByName("Accept-Ranges", "none", 1)
62+ // Streaming — unknown length.
63+ *(*int64)(responseLength) = -1
64+}
65+
66+// Read copies transcoded data from the session into the CEF output buffer.
67+func (rh *transcodingResourceHandler) Read(
68+ dataOut unsafe.Pointer, bytesToRead int32,
69+ bytesRead unsafe.Pointer, _ purecef.ResourceReadCallback,
70+) int32 {
71+ if rh.session == nil {
72+ return 0
73+ }
74+
75+ buf := make([]byte, bytesToRead)
76+ n, err := rh.session.Read(buf)
77+ if n > 0 {
78+ dst := unsafe.Slice((*byte)(dataOut), n)
79+ copy(dst, buf[:n])
80+ *(*int32)(bytesRead) = int32(n)
81+ return 1
82+ }
83+ if err != nil {
84+ return 0 // EOF or error
85+ }
86+ return 0
87+}
88+
89+// Cancel aborts the transcode session and releases resources.
90+func (rh *transcodingResourceHandler) Cancel() {
91+ if rh.session != nil {
92+ rh.session.Close()
93+ }
94+ if rh.cancel != nil {
95+ rh.cancel()
96+ }
97+}
98+
99+// ProcessRequest is deprecated; Open is used instead.
100+func (rh *transcodingResourceHandler) ProcessRequest(_ purecef.Request, _ purecef.Callback) int32 {
101+ return 0
102+}
103+
104+// ReadResponse is deprecated; Read is used instead.
105+func (rh *transcodingResourceHandler) ReadResponse(_ unsafe.Pointer, _ int32, _ unsafe.Pointer, _ purecef.Callback) int32 {
106+ return 0
107+}
108+
109+// Skip is not used for streaming content.
110+func (rh *transcodingResourceHandler) Skip(_ int64, _ unsafe.Pointer, _ purecef.ResourceSkipCallback) int32 {
111+ return 0
112+}
113+
114+// ---------------------------------------------------------------------------
115+// transcodingRequestHandler — ResourceRequestHandler that intercepts
116+// proprietary video responses and restarts them through the transcoder
117+// ---------------------------------------------------------------------------
118+
119+type transcodingRequestHandler struct {
120+ transcoder port.MediaTranscoder
121+ transcodableURLs sync.Map // url string -> requestInfo
122+}
123+
124+type requestInfo struct {
125+ headers map[string]string
126+}
127+
128+// newTranscodingRequestHandler creates a ResourceRequestHandler that detects
129+// proprietary video MIME types and restarts those requests through a GPU
130+// transcoding ResourceHandler.
131+func newTranscodingRequestHandler(tc port.MediaTranscoder) purecef.ResourceRequestHandler {
132+ return purecef.NewResourceRequestHandler(&transcodingRequestHandler{
133+ transcoder: tc,
134+ })
135+}
136+
137+// OnResourceResponse inspects the MIME type of every response. If it matches
138+// a proprietary video format (H.264, HEVC, etc.), it stores the request info
139+// and returns 1 to restart the request — at which point GetResourceHandler
140+// will provide the transcoding handler.
141+func (h *transcodingRequestHandler) OnResourceResponse(_ purecef.Browser, _ purecef.Frame, request purecef.Request, response purecef.Response) int32 {
142+ if response == nil {
143+ return 0
144+ }
145+ mimeType := response.GetMimeType()
146+ if !transcoder.IsProprietaryVideoMIME(mimeType) {
147+ return 0
148+ }
149+
150+ // Extract relevant headers from the original request so the transcoder
151+ // can authenticate with the source server.
152+ headers := make(map[string]string)
153+ if cookie := request.GetHeaderByName("Cookie"); cookie != "" {
154+ headers["Cookie"] = cookie
155+ }
156+ if auth := request.GetHeaderByName("Authorization"); auth != "" {
157+ headers["Authorization"] = auth
158+ }
159+ if referer := request.GetHeaderByName("Referer"); referer != "" {
160+ headers["Referer"] = referer
161+ }
162+
163+ url := request.GetURL()
164+ h.transcodableURLs.Store(url, requestInfo{headers: headers})
165+
166+ return 1 // restart request
167+}
168+
169+// GetResourceHandler returns a transcoding ResourceHandler for URLs that were
170+// previously identified as proprietary video by OnResourceResponse.
171+func (h *transcodingRequestHandler) GetResourceHandler(_ purecef.Browser, _ purecef.Frame, request purecef.Request) purecef.ResourceHandler {
172+ if request == nil {
173+ return nil
174+ }
175+ url := request.GetURL()
176+ val, ok := h.transcodableURLs.LoadAndDelete(url)
177+ if !ok {
178+ return nil
179+ }
180+ info := val.(requestInfo)
181+
182+ ctx, cancel := context.WithCancel(context.Background())
183+ return purecef.NewResourceHandler(&transcodingResourceHandler{
184+ transcoder: h.transcoder,
185+ sourceURL: url,
186+ headers: info.headers,
187+ ctx: ctx,
188+ cancel: cancel,
189+ })
190+}
191+
192+// --- No-op methods required by purecef.ResourceRequestHandler ---
193+
194+func (h *transcodingRequestHandler) GetCookieAccessFilter(_ purecef.Browser, _ purecef.Frame, _ purecef.Request) purecef.CookieAccessFilter {
195+ return nil
196+}
197+
198+func (h *transcodingRequestHandler) OnBeforeResourceLoad(_ purecef.Browser, _ purecef.Frame, _ purecef.Request, _ purecef.Callback) purecef.ReturnValue {
199+ return purecef.ReturnValueRvContinue
200+}
201+
202+func (h *transcodingRequestHandler) OnResourceRedirect(_ purecef.Browser, _ purecef.Frame, _ purecef.Request, _ purecef.Response, _ uintptr) {
203+}
204+
205+func (h *transcodingRequestHandler) GetResourceResponseFilter(_ purecef.Browser, _ purecef.Frame, _ purecef.Request, _ purecef.Response) purecef.ResponseFilter {
206+ return nil
207+}
208+
209+func (h *transcodingRequestHandler) OnResourceLoadComplete(_ purecef.Browser, _ purecef.Frame, _ purecef.Request, _ purecef.Response, _ purecef.UrlrequestStatus, _ int64) {
210+}
211+
212+func (h *transcodingRequestHandler) OnProtocolExecution(_ purecef.Browser, _ purecef.Frame, _ purecef.Request, _ unsafe.Pointer) {
213+}
M internal/infrastructure/cef/webview_handlers.go +4 -0
@@ -13,6 +13,7 @@ import (
1313 type handlerSet struct {
1414 wv *WebView
1515 enableContextMenuHandler bool
16+ transcodingHandler purecef.ResourceRequestHandler
1617 }
1718
1819 // Compile-time interface checks.
@@ -559,6 +560,9 @@ func (h *handlerSet) GetResourceRequestHandler(
559560 _ purecef.Browser, _ purecef.Frame, _ purecef.Request,
560561 _, _ int32, _ string, _ *int32,
561562 ) purecef.ResourceRequestHandler {
563+ if h.transcodingHandler != nil {
564+ return h.transcodingHandler
565+ }
562566 return nil
563567 }
564568
@@ -13,6 +13,7 @@ import (
13 type handlerSet struct {13 type handlerSet struct {
14 wv *WebView14 wv *WebView
15 enableContextMenuHandler bool15 enableContextMenuHandler bool
16+ transcodingHandler purecef.ResourceRequestHandler
16 }17 }
17 18
18 // Compile-time interface checks.19 // Compile-time interface checks.
@@ -559,6 +560,9 @@ func (h *handlerSet) GetResourceRequestHandler(
559 _ purecef.Browser, _ purecef.Frame, _ purecef.Request,560 _ purecef.Browser, _ purecef.Frame, _ purecef.Request,
560 _, _ int32, _ string, _ *int32,561 _, _ int32, _ string, _ *int32,
561 ) purecef.ResourceRequestHandler {562 ) purecef.ResourceRequestHandler {
563+ if h.transcodingHandler != nil {
564+ return h.transcodingHandler
565+ }
562 return nil566 return nil
563 }567 }
564 568
M internal/infrastructure/config/schema.go +19 -0
@@ -98,6 +98,8 @@ type Config struct {
9898 Downloads DownloadsConfig `mapstructure:"downloads" yaml:"downloads" toml:"downloads"`
9999 // Engine holds engine selection and unified engine options.
100100 Engine EngineConfig `mapstructure:"engine" toml:"engine" yaml:"engine"`
101+ // Transcoding controls GPU-accelerated media transcoding for proprietary codecs.
102+ Transcoding TranscodingConfig `mapstructure:"transcoding" yaml:"transcoding" toml:"transcoding"`
101103 }
102104
103105 // CookiePolicy controls cookie acceptance behavior.
@@ -362,3 +364,20 @@ type DownloadsConfig struct {
362364 // Empty string means use XDG_DOWNLOAD_DIR or ~/Downloads.
363365 Path string `mapstructure:"path" yaml:"path" toml:"path"`
364366 }
367+
368+// TranscodingConfig controls GPU-accelerated media transcoding.
369+// When enabled, proprietary video formats (H.264, HEVC) are transcoded
370+// to open codecs (VP9, AV1) that CEF can decode natively.
371+type TranscodingConfig struct {
372+ // Enabled controls whether transcoding is active (default: false).
373+ Enabled bool `mapstructure:"enabled" yaml:"enabled" toml:"enabled"`
374+ // HWAccel selects the hardware acceleration API: "auto", "vaapi", or "nvenc".
375+ // Default: "auto" (tries VAAPI first, then CUDA).
376+ HWAccel string `mapstructure:"hwaccel" yaml:"hwaccel" toml:"hwaccel"`
377+ // MaxConcurrent is the maximum number of simultaneous transcode sessions.
378+ // Default: 2.
379+ MaxConcurrent int `mapstructure:"max_concurrent" yaml:"max_concurrent" toml:"max_concurrent"`
380+ // Quality controls the encode quality preset: "low", "medium", or "high".
381+ // Default: "medium".
382+ Quality string `mapstructure:"quality" yaml:"quality" toml:"quality"`
383+}
@@ -98,6 +98,8 @@ type Config struct {
98 Downloads DownloadsConfig `mapstructure:"downloads" yaml:"downloads" toml:"downloads"`98 Downloads DownloadsConfig `mapstructure:"downloads" yaml:"downloads" toml:"downloads"`
99 // Engine holds engine selection and unified engine options.99 // Engine holds engine selection and unified engine options.
100 Engine EngineConfig `mapstructure:"engine" toml:"engine" yaml:"engine"`100 Engine EngineConfig `mapstructure:"engine" toml:"engine" yaml:"engine"`
101+ // Transcoding controls GPU-accelerated media transcoding for proprietary codecs.
102+ Transcoding TranscodingConfig `mapstructure:"transcoding" yaml:"transcoding" toml:"transcoding"`
101 }103 }
102 104
103 // CookiePolicy controls cookie acceptance behavior.105 // CookiePolicy controls cookie acceptance behavior.
@@ -362,3 +364,20 @@ type DownloadsConfig struct {
362 // Empty string means use XDG_DOWNLOAD_DIR or ~/Downloads.364 // Empty string means use XDG_DOWNLOAD_DIR or ~/Downloads.
363 Path string `mapstructure:"path" yaml:"path" toml:"path"`365 Path string `mapstructure:"path" yaml:"path" toml:"path"`
364 }366 }
367+
368+// TranscodingConfig controls GPU-accelerated media transcoding.
369+// When enabled, proprietary video formats (H.264, HEVC) are transcoded
370+// to open codecs (VP9, AV1) that CEF can decode natively.
371+type TranscodingConfig struct {
372+ // Enabled controls whether transcoding is active (default: false).
373+ Enabled bool `mapstructure:"enabled" yaml:"enabled" toml:"enabled"`
374+ // HWAccel selects the hardware acceleration API: "auto", "vaapi", or "nvenc".
375+ // Default: "auto" (tries VAAPI first, then CUDA).
376+ HWAccel string `mapstructure:"hwaccel" yaml:"hwaccel" toml:"hwaccel"`
377+ // MaxConcurrent is the maximum number of simultaneous transcode sessions.
378+ // Default: 2.
379+ MaxConcurrent int `mapstructure:"max_concurrent" yaml:"max_concurrent" toml:"max_concurrent"`
380+ // Quality controls the encode quality preset: "low", "medium", or "high".
381+ // Default: "medium".
382+ Quality string `mapstructure:"quality" yaml:"quality" toml:"quality"`
383+}
A internal/infrastructure/transcoder/audio.go +373 -0
@@ -0,0 +1,373 @@
1+package transcoder
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "unsafe"
7+
8+ "github.com/bnema/purego-ffmpeg/ffmpeg"
9+)
10+
11+// opusSampleRate is the standard output sample rate for Opus encoding.
12+// Opus supports 8, 12, 16, 24, and 48 kHz. 48 kHz is the canonical rate.
13+const opusSampleRate = 48000
14+
15+// opusBitrate is the default Opus encoding bitrate (128 kbps stereo).
16+const opusBitrate = 128000
17+
18+// opusFrameSize is the number of samples per Opus frame at 48 kHz.
19+// 960 samples = 20 ms at 48 kHz, which is the standard Opus frame duration.
20+const opusFrameSize = 960
21+
22+// audioTranscoder decodes an input audio stream (typically AAC) and
23+// re-encodes it to Opus, performing sample rate conversion if needed.
24+type audioTranscoder struct {
25+ decoder unsafe.Pointer // AVCodecContext* for decoding
26+ encoder unsafe.Pointer // AVCodecContext* for Opus encoding
27+ resampler unsafe.Pointer // SwrContext* (nil if no resampling needed)
28+ frame unsafe.Pointer // reusable AVFrame for resampled output
29+ inStream int // input stream index in the source container
30+ outStream int // output stream index in the muxer
31+}
32+
33+// newAudioTranscoder sets up an AAC (or other) decoder and a libopus encoder.
34+// inCodecPar is the AVCodecParameters* from the input audio stream.
35+// outFmtCtx is the AVFormatContext* for the output container (used to
36+// add the output stream). inStreamIdx is the input stream index.
37+func newAudioTranscoder(inCodecPar, outFmtCtx unsafe.Pointer, inStreamIdx int) (*audioTranscoder, error) {
38+ // --- Decoder setup ---
39+ codecID := ffmpeg.CodecParCodecID(inCodecPar)
40+ dec := ffmpeg.CodecFindDecoder(codecID)
41+ if dec == nil {
42+ return nil, fmt.Errorf("audio decoder not found for codec ID %d", codecID)
43+ }
44+
45+ decCtx := ffmpeg.CodecAllocContext3(dec)
46+ if decCtx == nil {
47+ return nil, errors.New("failed to allocate audio decoder context")
48+ }
49+
50+ if ret := ffmpeg.CodecParametersToContext(decCtx, inCodecPar); ret < 0 {
51+ ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx))
52+ return nil, fmt.Errorf("failed to copy audio codec parameters to decoder: %d", ret)
53+ }
54+
55+ if ret := ffmpeg.CodecOpen2(decCtx, dec, nil); ret < 0 {
56+ ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx))
57+ return nil, fmt.Errorf("failed to open audio decoder: %d", ret)
58+ }
59+
60+ // --- Encoder setup (libopus) ---
61+ enc := ffmpeg.CodecFindEncoderByName("libopus")
62+ if enc == nil {
63+ ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx))
64+ return nil, errors.New("libopus encoder not found")
65+ }
66+
67+ encCtx := ffmpeg.CodecAllocContext3(enc)
68+ if encCtx == nil {
69+ ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx))
70+ return nil, errors.New("failed to allocate audio encoder context")
71+ }
72+
73+ // Opus always works with float planar samples.
74+ ffmpeg.CodecCtxSetSampleFmt(encCtx, int32(ffmpeg.SampleFormatSampleFmtFltp))
75+ ffmpeg.CodecCtxSetSampleRate(encCtx, opusSampleRate)
76+ codecCtxSetBitRate(encCtx, opusBitrate)
77+
78+ // Copy channel layout from decoder to encoder.
79+ codecCtxCopyChLayout(encCtx, decCtx)
80+
81+ // Set time base to 1/sample_rate for audio.
82+ ffmpeg.CodecCtxSetTimeBase(encCtx, ffmpeg.AVRational{Num: 1, Den: opusSampleRate})
83+
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+ }
91+
92+ if ret := ffmpeg.CodecOpen2(encCtx, enc, nil); ret < 0 {
93+ ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx))
94+ ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx))
95+ return nil, fmt.Errorf("failed to open audio encoder: %d", ret)
96+ }
97+
98+ // --- Add output stream ---
99+ outStreamPtr := ffmpeg.FormatNewStream(outFmtCtx, nil)
100+ if outStreamPtr == nil {
101+ ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx))
102+ ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx))
103+ return nil, errors.New("failed to create output audio stream")
104+ }
105+
106+ outStreamWrap := (*ffmpeg.Stream)(outStreamPtr)
107+ outStreamIdx := int(outStreamWrap.Index())
108+
109+ // Copy encoder parameters to the output stream's codecpar.
110+ if ret := ffmpeg.CodecParametersFromContext(outStreamWrap.Codecpar(), encCtx); ret < 0 {
111+ ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx))
112+ ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx))
113+ return nil, fmt.Errorf("failed to copy audio encoder params to stream: %d", ret)
114+ }
115+
116+ // --- Resampler setup (if sample rates differ) ---
117+ var swr unsafe.Pointer
118+ var resampledFrame unsafe.Pointer
119+
120+ inSampleRate := ffmpeg.CodecCtxSampleRate(decCtx)
121+ inSampleFmt := ffmpeg.CodecCtxSampleFmt(decCtx)
122+ needResample := inSampleRate != opusSampleRate || inSampleFmt != int32(ffmpeg.SampleFormatSampleFmtFltp)
123+
124+ if needResample {
125+ swr = ffmpeg.SwrAlloc()
126+ if swr == nil {
127+ ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx))
128+ ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx))
129+ return nil, errors.New("failed to allocate resampler")
130+ }
131+
132+ // Configure resampler via av_opt_set on the SwrContext.
133+ ffmpeg.AVOptSetInt(swr, "in_sample_rate", int64(inSampleRate), 0)
134+ ffmpeg.AVOptSetInt(swr, "out_sample_rate", int64(opusSampleRate), 0)
135+ ffmpeg.AVOptSet(swr, "in_sample_fmt", ffmpeg.GetSampleFmtName(inSampleFmt), 0)
136+ ffmpeg.AVOptSet(swr, "out_sample_fmt", ffmpeg.GetSampleFmtName(int32(ffmpeg.SampleFormatSampleFmtFltp)), 0)
137+
138+ // Channel layout is inherited from the decoder context via av_opt.
139+ // SwrContext reads in_chlayout / out_chlayout — we keep them identical.
140+
141+ if ret := ffmpeg.SwrInit(swr); ret < 0 {
142+ ffmpeg.SwrFree(unsafe.Pointer(&swr))
143+ ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx))
144+ ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx))
145+ return nil, fmt.Errorf("failed to init resampler: %d", ret)
146+ }
147+
148+ // Allocate a reusable output frame for resampled data.
149+ resampledFrame = ffmpeg.FrameAlloc()
150+ if resampledFrame == nil {
151+ ffmpeg.SwrFree(unsafe.Pointer(&swr))
152+ ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx))
153+ ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx))
154+ return nil, errors.New("failed to allocate resampled frame")
155+ }
156+
157+ frameSetFormat(resampledFrame, int32(ffmpeg.SampleFormatSampleFmtFltp))
158+ frameSetSampleRate(resampledFrame, opusSampleRate)
159+ frameSetNbSamples(resampledFrame, opusFrameSize)
160+ // Copy channel layout from decoder to the resampled frame.
161+ codecCtxCopyChLayout(resampledFrame, decCtx)
162+ }
163+
164+ return &audioTranscoder{
165+ decoder: decCtx,
166+ encoder: encCtx,
167+ resampler: swr,
168+ frame: resampledFrame,
169+ inStream: inStreamIdx,
170+ outStream: outStreamIdx,
171+ }, nil
172+}
173+
174+// processPacket decodes an audio packet, optionally resamples, encodes
175+// to Opus, and writes the resulting packets to outFmtCtx.
176+func (a *audioTranscoder) processPacket(pkt, outFmtCtx unsafe.Pointer) error {
177+ // Send packet to decoder.
178+ ret := ffmpeg.CodecSendPacket(a.decoder, pkt)
179+ if ret < 0 {
180+ return fmt.Errorf("audio decode send packet: %d", ret)
181+ }
182+
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))
194+
195+ for {
196+ ret = ffmpeg.CodecReceiveFrame(a.decoder, frame)
197+ if ret < 0 {
198+ // EAGAIN or EOF — no more frames from this packet.
199+ break
200+ }
201+
202+ // Determine which frame to send to the encoder.
203+ encFrame := frame
204+ if a.resampler != nil {
205+ encFrame = a.frame
206+
207+ // Get the decoded frame's data and linesize pointers.
208+ // Frame data[0] is at offset 0, linesize is not needed for swr.
209+ inData := (*unsafe.Pointer)(frame)
210+ outData := (*unsafe.Pointer)(a.frame)
211+ inSamples := frameNbSamples(frame)
212+
213+ // Allocate buffer for output frame if needed.
214+ frameSetNbSamples(a.frame, opusFrameSize)
215+ if ret := ffmpeg.FrameGetBuffer(a.frame, 0); ret < 0 {
216+ ffmpeg.FrameUnref(frame)
217+ return fmt.Errorf("failed to allocate resampled frame buffer: %d", ret)
218+ }
219+
220+ converted := ffmpeg.SwrConvert(
221+ a.resampler,
222+ unsafe.Pointer(outData), opusFrameSize,
223+ unsafe.Pointer(inData), inSamples,
224+ )
225+ if converted < 0 {
226+ ffmpeg.FrameUnref(frame)
227+ return fmt.Errorf("audio resample failed: %d", converted)
228+ }
229+ frameSetNbSamples(a.frame, converted)
230+
231+ // Copy pts from decoded frame, rescaling for the new sample rate.
232+ pts := framePts(frame)
233+ if pts >= 0 {
234+ decTB := ffmpeg.CodecCtxTimeBase(a.decoder)
235+ encTB := ffmpeg.CodecCtxTimeBase(a.encoder)
236+ frameSetPts(a.frame, ffmpeg.RescaleQ(pts, decTB, encTB))
237+ }
238+ }
239+
240+ // Send frame to encoder.
241+ if ret := ffmpeg.CodecSendFrame(a.encoder, encFrame); ret < 0 {
242+ ffmpeg.FrameUnref(frame)
243+ return fmt.Errorf("audio encode send frame: %d", ret)
244+ }
245+
246+ // Receive encoded packets.
247+ for {
248+ ret = ffmpeg.CodecReceivePacket(a.encoder, encPkt)
249+ if ret < 0 {
250+ break
251+ }
252+
253+ pktSetStreamIndex(encPkt, int32(a.outStream))
254+
255+ // Rescale timestamps from encoder time base to output stream time base.
256+ outStream := ffmpeg.FmtCtxStream(outFmtCtx, a.outStream)
257+ outStreamWrap := (*ffmpeg.Stream)(outStream)
258+ ffmpeg.PacketRescaleTs(encPkt, ffmpeg.CodecCtxTimeBase(a.encoder), outStreamWrap.TimeBase())
259+
260+ if wret := ffmpeg.InterleavedWriteFrame(outFmtCtx, encPkt); wret < 0 {
261+ ffmpeg.PacketUnref(encPkt)
262+ ffmpeg.FrameUnref(frame)
263+ return fmt.Errorf("audio write frame: %d", wret)
264+ }
265+ ffmpeg.PacketUnref(encPkt)
266+ }
267+
268+ ffmpeg.FrameUnref(frame)
269+ }
270+
271+ return nil
272+}
273+
274+// flush drains remaining frames from the decoder and encoder.
275+func (a *audioTranscoder) flush(outFmtCtx unsafe.Pointer) error {
276+ // Flush decoder by sending NULL packet.
277+ ffmpeg.CodecSendPacket(a.decoder, nil)
278+
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))
290+
291+ // Drain decoded frames.
292+ for {
293+ ret := ffmpeg.CodecReceiveFrame(a.decoder, frame)
294+ if ret < 0 {
295+ break
296+ }
297+
298+ encFrame := frame
299+ if a.resampler != nil {
300+ encFrame = a.frame
301+ inData := (*unsafe.Pointer)(frame)
302+ outData := (*unsafe.Pointer)(a.frame)
303+ inSamples := frameNbSamples(frame)
304+
305+ frameSetNbSamples(a.frame, opusFrameSize)
306+ if ret := ffmpeg.FrameGetBuffer(a.frame, 0); ret < 0 {
307+ ffmpeg.FrameUnref(frame)
308+ continue
309+ }
310+
311+ converted := ffmpeg.SwrConvert(
312+ a.resampler,
313+ unsafe.Pointer(outData), opusFrameSize,
314+ unsafe.Pointer(inData), inSamples,
315+ )
316+ if converted < 0 {
317+ ffmpeg.FrameUnref(frame)
318+ continue
319+ }
320+ frameSetNbSamples(a.frame, converted)
321+ }
322+
323+ ffmpeg.CodecSendFrame(a.encoder, encFrame)
324+
325+ for {
326+ ret := ffmpeg.CodecReceivePacket(a.encoder, encPkt)
327+ if ret < 0 {
328+ break
329+ }
330+ pktSetStreamIndex(encPkt, int32(a.outStream))
331+ outStream := ffmpeg.FmtCtxStream(outFmtCtx, a.outStream)
332+ outStreamWrap := (*ffmpeg.Stream)(outStream)
333+ ffmpeg.PacketRescaleTs(encPkt, ffmpeg.CodecCtxTimeBase(a.encoder), outStreamWrap.TimeBase())
334+ ffmpeg.InterleavedWriteFrame(outFmtCtx, encPkt)
335+ ffmpeg.PacketUnref(encPkt)
336+ }
337+
338+ ffmpeg.FrameUnref(frame)
339+ }
340+
341+ // Flush encoder by sending NULL frame.
342+ ffmpeg.CodecSendFrame(a.encoder, nil)
343+ for {
344+ ret := ffmpeg.CodecReceivePacket(a.encoder, encPkt)
345+ if ret < 0 {
346+ break
347+ }
348+ pktSetStreamIndex(encPkt, int32(a.outStream))
349+ outStream := ffmpeg.FmtCtxStream(outFmtCtx, a.outStream)
350+ outStreamWrap := (*ffmpeg.Stream)(outStream)
351+ ffmpeg.PacketRescaleTs(encPkt, ffmpeg.CodecCtxTimeBase(a.encoder), outStreamWrap.TimeBase())
352+ ffmpeg.InterleavedWriteFrame(outFmtCtx, encPkt)
353+ ffmpeg.PacketUnref(encPkt)
354+ }
355+
356+ return nil
357+}
358+
359+// close frees all FFmpeg resources owned by the audio transcoder.
360+func (a *audioTranscoder) close() {
361+ if a.frame != nil {
362+ ffmpeg.FrameFree(unsafe.Pointer(&a.frame))
363+ }
364+ if a.resampler != nil {
365+ ffmpeg.SwrFree(unsafe.Pointer(&a.resampler))
366+ }
367+ if a.encoder != nil {
368+ ffmpeg.CodecFreeContext(unsafe.Pointer(&a.encoder))
369+ }
370+ if a.decoder != nil {
371+ ffmpeg.CodecFreeContext(unsafe.Pointer(&a.decoder))
372+ }
373+}
@@ -0,0 +1,373 @@
1+package transcoder
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "unsafe"
7+
8+ "github.com/bnema/purego-ffmpeg/ffmpeg"
9+)
10+
11+// opusSampleRate is the standard output sample rate for Opus encoding.
12+// Opus supports 8, 12, 16, 24, and 48 kHz. 48 kHz is the canonical rate.
13+const opusSampleRate = 48000
14+
15+// opusBitrate is the default Opus encoding bitrate (128 kbps stereo).
16+const opusBitrate = 128000
17+
18+// opusFrameSize is the number of samples per Opus frame at 48 kHz.
19+// 960 samples = 20 ms at 48 kHz, which is the standard Opus frame duration.
20+const opusFrameSize = 960
21+
22+// audioTranscoder decodes an input audio stream (typically AAC) and
23+// re-encodes it to Opus, performing sample rate conversion if needed.
24+type audioTranscoder struct {
25+ decoder unsafe.Pointer // AVCodecContext* for decoding
26+ encoder unsafe.Pointer // AVCodecContext* for Opus encoding
27+ resampler unsafe.Pointer // SwrContext* (nil if no resampling needed)
28+ frame unsafe.Pointer // reusable AVFrame for resampled output
29+ inStream int // input stream index in the source container
30+ outStream int // output stream index in the muxer
31+}
32+
33+// newAudioTranscoder sets up an AAC (or other) decoder and a libopus encoder.
34+// inCodecPar is the AVCodecParameters* from the input audio stream.
35+// outFmtCtx is the AVFormatContext* for the output container (used to
36+// add the output stream). inStreamIdx is the input stream index.
37+func newAudioTranscoder(inCodecPar, outFmtCtx unsafe.Pointer, inStreamIdx int) (*audioTranscoder, error) {
38+ // --- Decoder setup ---
39+ codecID := ffmpeg.CodecParCodecID(inCodecPar)
40+ dec := ffmpeg.CodecFindDecoder(codecID)
41+ if dec == nil {
42+ return nil, fmt.Errorf("audio decoder not found for codec ID %d", codecID)
43+ }
44+
45+ decCtx := ffmpeg.CodecAllocContext3(dec)
46+ if decCtx == nil {
47+ return nil, errors.New("failed to allocate audio decoder context")
48+ }
49+
50+ if ret := ffmpeg.CodecParametersToContext(decCtx, inCodecPar); ret < 0 {
51+ ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx))
52+ return nil, fmt.Errorf("failed to copy audio codec parameters to decoder: %d", ret)
53+ }
54+
55+ if ret := ffmpeg.CodecOpen2(decCtx, dec, nil); ret < 0 {
56+ ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx))
57+ return nil, fmt.Errorf("failed to open audio decoder: %d", ret)
58+ }
59+
60+ // --- Encoder setup (libopus) ---
61+ enc := ffmpeg.CodecFindEncoderByName("libopus")
62+ if enc == nil {
63+ ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx))
64+ return nil, errors.New("libopus encoder not found")
65+ }
66+
67+ encCtx := ffmpeg.CodecAllocContext3(enc)
68+ if encCtx == nil {
69+ ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx))
70+ return nil, errors.New("failed to allocate audio encoder context")
71+ }
72+
73+ // Opus always works with float planar samples.
74+ ffmpeg.CodecCtxSetSampleFmt(encCtx, int32(ffmpeg.SampleFormatSampleFmtFltp))
75+ ffmpeg.CodecCtxSetSampleRate(encCtx, opusSampleRate)
76+ codecCtxSetBitRate(encCtx, opusBitrate)
77+
78+ // Copy channel layout from decoder to encoder.
79+ codecCtxCopyChLayout(encCtx, decCtx)
80+
81+ // Set time base to 1/sample_rate for audio.
82+ ffmpeg.CodecCtxSetTimeBase(encCtx, ffmpeg.AVRational{Num: 1, Den: opusSampleRate})
83+
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+ }
91+
92+ if ret := ffmpeg.CodecOpen2(encCtx, enc, nil); ret < 0 {
93+ ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx))
94+ ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx))
95+ return nil, fmt.Errorf("failed to open audio encoder: %d", ret)
96+ }
97+
98+ // --- Add output stream ---
99+ outStreamPtr := ffmpeg.FormatNewStream(outFmtCtx, nil)
100+ if outStreamPtr == nil {
101+ ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx))
102+ ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx))
103+ return nil, errors.New("failed to create output audio stream")
104+ }
105+
106+ outStreamWrap := (*ffmpeg.Stream)(outStreamPtr)
107+ outStreamIdx := int(outStreamWrap.Index())
108+
109+ // Copy encoder parameters to the output stream's codecpar.
110+ if ret := ffmpeg.CodecParametersFromContext(outStreamWrap.Codecpar(), encCtx); ret < 0 {
111+ ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx))
112+ ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx))
113+ return nil, fmt.Errorf("failed to copy audio encoder params to stream: %d", ret)
114+ }
115+
116+ // --- Resampler setup (if sample rates differ) ---
117+ var swr unsafe.Pointer
118+ var resampledFrame unsafe.Pointer
119+
120+ inSampleRate := ffmpeg.CodecCtxSampleRate(decCtx)
121+ inSampleFmt := ffmpeg.CodecCtxSampleFmt(decCtx)
122+ needResample := inSampleRate != opusSampleRate || inSampleFmt != int32(ffmpeg.SampleFormatSampleFmtFltp)
123+
124+ if needResample {
125+ swr = ffmpeg.SwrAlloc()
126+ if swr == nil {
127+ ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx))
128+ ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx))
129+ return nil, errors.New("failed to allocate resampler")
130+ }
131+
132+ // Configure resampler via av_opt_set on the SwrContext.
133+ ffmpeg.AVOptSetInt(swr, "in_sample_rate", int64(inSampleRate), 0)
134+ ffmpeg.AVOptSetInt(swr, "out_sample_rate", int64(opusSampleRate), 0)
135+ ffmpeg.AVOptSet(swr, "in_sample_fmt", ffmpeg.GetSampleFmtName(inSampleFmt), 0)
136+ ffmpeg.AVOptSet(swr, "out_sample_fmt", ffmpeg.GetSampleFmtName(int32(ffmpeg.SampleFormatSampleFmtFltp)), 0)
137+
138+ // Channel layout is inherited from the decoder context via av_opt.
139+ // SwrContext reads in_chlayout / out_chlayout — we keep them identical.
140+
141+ if ret := ffmpeg.SwrInit(swr); ret < 0 {
142+ ffmpeg.SwrFree(unsafe.Pointer(&swr))
143+ ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx))
144+ ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx))
145+ return nil, fmt.Errorf("failed to init resampler: %d", ret)
146+ }
147+
148+ // Allocate a reusable output frame for resampled data.
149+ resampledFrame = ffmpeg.FrameAlloc()
150+ if resampledFrame == nil {
151+ ffmpeg.SwrFree(unsafe.Pointer(&swr))
152+ ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx))
153+ ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx))
154+ return nil, errors.New("failed to allocate resampled frame")
155+ }
156+
157+ frameSetFormat(resampledFrame, int32(ffmpeg.SampleFormatSampleFmtFltp))
158+ frameSetSampleRate(resampledFrame, opusSampleRate)
159+ frameSetNbSamples(resampledFrame, opusFrameSize)
160+ // Copy channel layout from decoder to the resampled frame.
161+ codecCtxCopyChLayout(resampledFrame, decCtx)
162+ }
163+
164+ return &audioTranscoder{
165+ decoder: decCtx,
166+ encoder: encCtx,
167+ resampler: swr,
168+ frame: resampledFrame,
169+ inStream: inStreamIdx,
170+ outStream: outStreamIdx,
171+ }, nil
172+}
173+
174+// processPacket decodes an audio packet, optionally resamples, encodes
175+// to Opus, and writes the resulting packets to outFmtCtx.
176+func (a *audioTranscoder) processPacket(pkt, outFmtCtx unsafe.Pointer) error {
177+ // Send packet to decoder.
178+ ret := ffmpeg.CodecSendPacket(a.decoder, pkt)
179+ if ret < 0 {
180+ return fmt.Errorf("audio decode send packet: %d", ret)
181+ }
182+
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))
194+
195+ for {
196+ ret = ffmpeg.CodecReceiveFrame(a.decoder, frame)
197+ if ret < 0 {
198+ // EAGAIN or EOF — no more frames from this packet.
199+ break
200+ }
201+
202+ // Determine which frame to send to the encoder.
203+ encFrame := frame
204+ if a.resampler != nil {
205+ encFrame = a.frame
206+
207+ // Get the decoded frame's data and linesize pointers.
208+ // Frame data[0] is at offset 0, linesize is not needed for swr.
209+ inData := (*unsafe.Pointer)(frame)
210+ outData := (*unsafe.Pointer)(a.frame)
211+ inSamples := frameNbSamples(frame)
212+
213+ // Allocate buffer for output frame if needed.
214+ frameSetNbSamples(a.frame, opusFrameSize)
215+ if ret := ffmpeg.FrameGetBuffer(a.frame, 0); ret < 0 {
216+ ffmpeg.FrameUnref(frame)
217+ return fmt.Errorf("failed to allocate resampled frame buffer: %d", ret)
218+ }
219+
220+ converted := ffmpeg.SwrConvert(
221+ a.resampler,
222+ unsafe.Pointer(outData), opusFrameSize,
223+ unsafe.Pointer(inData), inSamples,
224+ )
225+ if converted < 0 {
226+ ffmpeg.FrameUnref(frame)
227+ return fmt.Errorf("audio resample failed: %d", converted)
228+ }
229+ frameSetNbSamples(a.frame, converted)
230+
231+ // Copy pts from decoded frame, rescaling for the new sample rate.
232+ pts := framePts(frame)
233+ if pts >= 0 {
234+ decTB := ffmpeg.CodecCtxTimeBase(a.decoder)
235+ encTB := ffmpeg.CodecCtxTimeBase(a.encoder)
236+ frameSetPts(a.frame, ffmpeg.RescaleQ(pts, decTB, encTB))
237+ }
238+ }
239+
240+ // Send frame to encoder.
241+ if ret := ffmpeg.CodecSendFrame(a.encoder, encFrame); ret < 0 {
242+ ffmpeg.FrameUnref(frame)
243+ return fmt.Errorf("audio encode send frame: %d", ret)
244+ }
245+
246+ // Receive encoded packets.
247+ for {
248+ ret = ffmpeg.CodecReceivePacket(a.encoder, encPkt)
249+ if ret < 0 {
250+ break
251+ }
252+
253+ pktSetStreamIndex(encPkt, int32(a.outStream))
254+
255+ // Rescale timestamps from encoder time base to output stream time base.
256+ outStream := ffmpeg.FmtCtxStream(outFmtCtx, a.outStream)
257+ outStreamWrap := (*ffmpeg.Stream)(outStream)
258+ ffmpeg.PacketRescaleTs(encPkt, ffmpeg.CodecCtxTimeBase(a.encoder), outStreamWrap.TimeBase())
259+
260+ if wret := ffmpeg.InterleavedWriteFrame(outFmtCtx, encPkt); wret < 0 {
261+ ffmpeg.PacketUnref(encPkt)
262+ ffmpeg.FrameUnref(frame)
263+ return fmt.Errorf("audio write frame: %d", wret)
264+ }
265+ ffmpeg.PacketUnref(encPkt)
266+ }
267+
268+ ffmpeg.FrameUnref(frame)
269+ }
270+
271+ return nil
272+}
273+
274+// flush drains remaining frames from the decoder and encoder.
275+func (a *audioTranscoder) flush(outFmtCtx unsafe.Pointer) error {
276+ // Flush decoder by sending NULL packet.
277+ ffmpeg.CodecSendPacket(a.decoder, nil)
278+
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))
290+
291+ // Drain decoded frames.
292+ for {
293+ ret := ffmpeg.CodecReceiveFrame(a.decoder, frame)
294+ if ret < 0 {
295+ break
296+ }
297+
298+ encFrame := frame
299+ if a.resampler != nil {
300+ encFrame = a.frame
301+ inData := (*unsafe.Pointer)(frame)
302+ outData := (*unsafe.Pointer)(a.frame)
303+ inSamples := frameNbSamples(frame)
304+
305+ frameSetNbSamples(a.frame, opusFrameSize)
306+ if ret := ffmpeg.FrameGetBuffer(a.frame, 0); ret < 0 {
307+ ffmpeg.FrameUnref(frame)
308+ continue
309+ }
310+
311+ converted := ffmpeg.SwrConvert(
312+ a.resampler,
313+ unsafe.Pointer(outData), opusFrameSize,
314+ unsafe.Pointer(inData), inSamples,
315+ )
316+ if converted < 0 {
317+ ffmpeg.FrameUnref(frame)
318+ continue
319+ }
320+ frameSetNbSamples(a.frame, converted)
321+ }
322+
323+ ffmpeg.CodecSendFrame(a.encoder, encFrame)
324+
325+ for {
326+ ret := ffmpeg.CodecReceivePacket(a.encoder, encPkt)
327+ if ret < 0 {
328+ break
329+ }
330+ pktSetStreamIndex(encPkt, int32(a.outStream))
331+ outStream := ffmpeg.FmtCtxStream(outFmtCtx, a.outStream)
332+ outStreamWrap := (*ffmpeg.Stream)(outStream)
333+ ffmpeg.PacketRescaleTs(encPkt, ffmpeg.CodecCtxTimeBase(a.encoder), outStreamWrap.TimeBase())
334+ ffmpeg.InterleavedWriteFrame(outFmtCtx, encPkt)
335+ ffmpeg.PacketUnref(encPkt)
336+ }
337+
338+ ffmpeg.FrameUnref(frame)
339+ }
340+
341+ // Flush encoder by sending NULL frame.
342+ ffmpeg.CodecSendFrame(a.encoder, nil)
343+ for {
344+ ret := ffmpeg.CodecReceivePacket(a.encoder, encPkt)
345+ if ret < 0 {
346+ break
347+ }
348+ pktSetStreamIndex(encPkt, int32(a.outStream))
349+ outStream := ffmpeg.FmtCtxStream(outFmtCtx, a.outStream)
350+ outStreamWrap := (*ffmpeg.Stream)(outStream)
351+ ffmpeg.PacketRescaleTs(encPkt, ffmpeg.CodecCtxTimeBase(a.encoder), outStreamWrap.TimeBase())
352+ ffmpeg.InterleavedWriteFrame(outFmtCtx, encPkt)
353+ ffmpeg.PacketUnref(encPkt)
354+ }
355+
356+ return nil
357+}
358+
359+// close frees all FFmpeg resources owned by the audio transcoder.
360+func (a *audioTranscoder) close() {
361+ if a.frame != nil {
362+ ffmpeg.FrameFree(unsafe.Pointer(&a.frame))
363+ }
364+ if a.resampler != nil {
365+ ffmpeg.SwrFree(unsafe.Pointer(&a.resampler))
366+ }
367+ if a.encoder != nil {
368+ ffmpeg.CodecFreeContext(unsafe.Pointer(&a.encoder))
369+ }
370+ if a.decoder != nil {
371+ ffmpeg.CodecFreeContext(unsafe.Pointer(&a.decoder))
372+ }
373+}
A internal/infrastructure/transcoder/detect.go +64 -0
@@ -0,0 +1,64 @@
1+package transcoder
2+
3+import (
4+ "mime"
5+ "strings"
6+)
7+
8+// proprietaryVideoMIMEs lists MIME types for containers that typically carry
9+// proprietary codecs (H.264, HEVC, AAC). These require GPU transcoding to
10+// open formats (VP8/VP9/AV1 + Opus) for playback in browsers without
11+// proprietary codec support.
12+var proprietaryVideoMIMEs = map[string]bool{
13+ "video/mp4": true, // H.264/HEVC + AAC
14+ "video/x-flv": true, // Flash Video (H.264 or Sorenson)
15+ "video/3gpp": true, // 3GPP (H.264 + AAC)
16+ "video/3gpp2": true, // 3GPP2 (H.264 + AAC)
17+ "video/mp2t": true, // MPEG-TS (usually H.264)
18+ "video/mpeg": true, // MPEG-1/2
19+ "video/x-msvideo": true, // AVI (often H.264)
20+ "video/x-matroska": true, // MKV (often H.264/HEVC)
21+ "video/quicktime": true, // MOV (H.264 + AAC)
22+ "video/x-m4v": true, // M4V (H.264 + AAC)
23+}
24+
25+// openVideoMIMEs lists MIME types for containers carrying open codecs
26+// (VP8/VP9/AV1 + Opus/Vorbis). These do not require transcoding.
27+var openVideoMIMEs = map[string]bool{
28+ "video/webm": true, // VP8/VP9/AV1 + Opus/Vorbis
29+ "video/ogg": true, // Theora + Vorbis
30+}
31+
32+// IsProprietaryVideoMIME returns true if the MIME type indicates a video
33+// format that typically carries proprietary codecs requiring transcoding.
34+func IsProprietaryVideoMIME(contentType string) bool {
35+ mediaType := parseMediaType(contentType)
36+ return proprietaryVideoMIMEs[mediaType]
37+}
38+
39+// IsOpenVideoMIME returns true if the MIME type indicates a video format
40+// with open codecs that do not require transcoding.
41+func IsOpenVideoMIME(contentType string) bool {
42+ mediaType := parseMediaType(contentType)
43+ return openVideoMIMEs[mediaType]
44+}
45+
46+// parseMediaType extracts the MIME type from a Content-Type header value,
47+// stripping parameters like charset or codecs.
48+func parseMediaType(contentType string) string {
49+ if contentType == "" {
50+ return ""
51+ }
52+ // mime.ParseMediaType handles the full RFC 2045 parsing, but is
53+ // overkill and can fail on non-conforming values. A simple split
54+ // on ";" is more resilient for our needs.
55+ mediaType, _, _ := mime.ParseMediaType(contentType)
56+ if mediaType != "" {
57+ return strings.ToLower(mediaType)
58+ }
59+ // Fallback: trim parameters manually
60+ if idx := strings.IndexByte(contentType, ';'); idx >= 0 {
61+ contentType = contentType[:idx]
62+ }
63+ return strings.ToLower(strings.TrimSpace(contentType))
64+}
@@ -0,0 +1,64 @@
1+package transcoder
2+
3+import (
4+ "mime"
5+ "strings"
6+)
7+
8+// proprietaryVideoMIMEs lists MIME types for containers that typically carry
9+// proprietary codecs (H.264, HEVC, AAC). These require GPU transcoding to
10+// open formats (VP8/VP9/AV1 + Opus) for playback in browsers without
11+// proprietary codec support.
12+var proprietaryVideoMIMEs = map[string]bool{
13+ "video/mp4": true, // H.264/HEVC + AAC
14+ "video/x-flv": true, // Flash Video (H.264 or Sorenson)
15+ "video/3gpp": true, // 3GPP (H.264 + AAC)
16+ "video/3gpp2": true, // 3GPP2 (H.264 + AAC)
17+ "video/mp2t": true, // MPEG-TS (usually H.264)
18+ "video/mpeg": true, // MPEG-1/2
19+ "video/x-msvideo": true, // AVI (often H.264)
20+ "video/x-matroska": true, // MKV (often H.264/HEVC)
21+ "video/quicktime": true, // MOV (H.264 + AAC)
22+ "video/x-m4v": true, // M4V (H.264 + AAC)
23+}
24+
25+// openVideoMIMEs lists MIME types for containers carrying open codecs
26+// (VP8/VP9/AV1 + Opus/Vorbis). These do not require transcoding.
27+var openVideoMIMEs = map[string]bool{
28+ "video/webm": true, // VP8/VP9/AV1 + Opus/Vorbis
29+ "video/ogg": true, // Theora + Vorbis
30+}
31+
32+// IsProprietaryVideoMIME returns true if the MIME type indicates a video
33+// format that typically carries proprietary codecs requiring transcoding.
34+func IsProprietaryVideoMIME(contentType string) bool {
35+ mediaType := parseMediaType(contentType)
36+ return proprietaryVideoMIMEs[mediaType]
37+}
38+
39+// IsOpenVideoMIME returns true if the MIME type indicates a video format
40+// with open codecs that do not require transcoding.
41+func IsOpenVideoMIME(contentType string) bool {
42+ mediaType := parseMediaType(contentType)
43+ return openVideoMIMEs[mediaType]
44+}
45+
46+// parseMediaType extracts the MIME type from a Content-Type header value,
47+// stripping parameters like charset or codecs.
48+func parseMediaType(contentType string) string {
49+ if contentType == "" {
50+ return ""
51+ }
52+ // mime.ParseMediaType handles the full RFC 2045 parsing, but is
53+ // overkill and can fail on non-conforming values. A simple split
54+ // on ";" is more resilient for our needs.
55+ mediaType, _, _ := mime.ParseMediaType(contentType)
56+ if mediaType != "" {
57+ return strings.ToLower(mediaType)
58+ }
59+ // Fallback: trim parameters manually
60+ if idx := strings.IndexByte(contentType, ';'); idx >= 0 {
61+ contentType = contentType[:idx]
62+ }
63+ return strings.ToLower(strings.TrimSpace(contentType))
64+}
A internal/infrastructure/transcoder/detect_test.go +86 -0
@@ -0,0 +1,86 @@
1+package transcoder
2+
3+import "testing"
4+
5+func TestIsProprietaryVideoMIME(t *testing.T) {
6+ tests := []struct {
7+ name string
8+ contentType string
9+ want bool
10+ }{
11+ // Proprietary formats
12+ {name: "mp4", contentType: "video/mp4", want: true},
13+ {name: "mp4 with codecs param", contentType: "video/mp4; codecs=\"avc1.42E01E\"", want: true},
14+ {name: "mp4 with charset", contentType: "video/mp4; charset=utf-8", want: true},
15+ {name: "flv", contentType: "video/x-flv", want: true},
16+ {name: "3gpp", contentType: "video/3gpp", want: true},
17+ {name: "3gpp2", contentType: "video/3gpp2", want: true},
18+ {name: "mpeg-ts", contentType: "video/mp2t", want: true},
19+ {name: "mpeg", contentType: "video/mpeg", want: true},
20+ {name: "avi", contentType: "video/x-msvideo", want: true},
21+ {name: "mkv", contentType: "video/x-matroska", want: true},
22+ {name: "quicktime", contentType: "video/quicktime", want: true},
23+ {name: "m4v", contentType: "video/x-m4v", want: true},
24+
25+ // Open formats (should be false)
26+ {name: "webm", contentType: "video/webm", want: false},
27+ {name: "ogg", contentType: "video/ogg", want: false},
28+
29+ // Non-video (should be false)
30+ {name: "audio mp3", contentType: "audio/mpeg", want: false},
31+ {name: "text html", contentType: "text/html", want: false},
32+ {name: "application json", contentType: "application/json", want: false},
33+ {name: "octet-stream", contentType: "application/octet-stream", want: false},
34+ {name: "empty", contentType: "", want: false},
35+
36+ // Case insensitivity
37+ {name: "mp4 uppercase", contentType: "Video/MP4", want: true},
38+ {name: "webm uppercase", contentType: "Video/WebM", want: false},
39+ }
40+
41+ for _, tt := range tests {
42+ t.Run(tt.name, func(t *testing.T) {
43+ got := IsProprietaryVideoMIME(tt.contentType)
44+ if got != tt.want {
45+ t.Errorf("IsProprietaryVideoMIME(%q) = %v, want %v", tt.contentType, got, tt.want)
46+ }
47+ })
48+ }
49+}
50+
51+func TestIsOpenVideoMIME(t *testing.T) {
52+ tests := []struct {
53+ name string
54+ contentType string
55+ want bool
56+ }{
57+ // Open formats
58+ {name: "webm", contentType: "video/webm", want: true},
59+ {name: "webm with codecs", contentType: "video/webm; codecs=\"vp9,opus\"", want: true},
60+ {name: "ogg", contentType: "video/ogg", want: true},
61+
62+ // Proprietary formats (should be false)
63+ {name: "mp4", contentType: "video/mp4", want: false},
64+ {name: "flv", contentType: "video/x-flv", want: false},
65+ {name: "mkv", contentType: "video/x-matroska", want: false},
66+
67+ // Non-video (should be false)
68+ {name: "audio ogg", contentType: "audio/ogg", want: false},
69+ {name: "text html", contentType: "text/html", want: false},
70+ {name: "octet-stream", contentType: "application/octet-stream", want: false},
71+ {name: "empty", contentType: "", want: false},
72+
73+ // Case insensitivity
74+ {name: "webm uppercase", contentType: "Video/WebM", want: true},
75+ {name: "ogg uppercase", contentType: "VIDEO/OGG", want: true},
76+ }
77+
78+ for _, tt := range tests {
79+ t.Run(tt.name, func(t *testing.T) {
80+ got := IsOpenVideoMIME(tt.contentType)
81+ if got != tt.want {
82+ t.Errorf("IsOpenVideoMIME(%q) = %v, want %v", tt.contentType, got, tt.want)
83+ }
84+ })
85+ }
86+}
@@ -0,0 +1,86 @@
1+package transcoder
2+
3+import "testing"
4+
5+func TestIsProprietaryVideoMIME(t *testing.T) {
6+ tests := []struct {
7+ name string
8+ contentType string
9+ want bool
10+ }{
11+ // Proprietary formats
12+ {name: "mp4", contentType: "video/mp4", want: true},
13+ {name: "mp4 with codecs param", contentType: "video/mp4; codecs=\"avc1.42E01E\"", want: true},
14+ {name: "mp4 with charset", contentType: "video/mp4; charset=utf-8", want: true},
15+ {name: "flv", contentType: "video/x-flv", want: true},
16+ {name: "3gpp", contentType: "video/3gpp", want: true},
17+ {name: "3gpp2", contentType: "video/3gpp2", want: true},
18+ {name: "mpeg-ts", contentType: "video/mp2t", want: true},
19+ {name: "mpeg", contentType: "video/mpeg", want: true},
20+ {name: "avi", contentType: "video/x-msvideo", want: true},
21+ {name: "mkv", contentType: "video/x-matroska", want: true},
22+ {name: "quicktime", contentType: "video/quicktime", want: true},
23+ {name: "m4v", contentType: "video/x-m4v", want: true},
24+
25+ // Open formats (should be false)
26+ {name: "webm", contentType: "video/webm", want: false},
27+ {name: "ogg", contentType: "video/ogg", want: false},
28+
29+ // Non-video (should be false)
30+ {name: "audio mp3", contentType: "audio/mpeg", want: false},
31+ {name: "text html", contentType: "text/html", want: false},
32+ {name: "application json", contentType: "application/json", want: false},
33+ {name: "octet-stream", contentType: "application/octet-stream", want: false},
34+ {name: "empty", contentType: "", want: false},
35+
36+ // Case insensitivity
37+ {name: "mp4 uppercase", contentType: "Video/MP4", want: true},
38+ {name: "webm uppercase", contentType: "Video/WebM", want: false},
39+ }
40+
41+ for _, tt := range tests {
42+ t.Run(tt.name, func(t *testing.T) {
43+ got := IsProprietaryVideoMIME(tt.contentType)
44+ if got != tt.want {
45+ t.Errorf("IsProprietaryVideoMIME(%q) = %v, want %v", tt.contentType, got, tt.want)
46+ }
47+ })
48+ }
49+}
50+
51+func TestIsOpenVideoMIME(t *testing.T) {
52+ tests := []struct {
53+ name string
54+ contentType string
55+ want bool
56+ }{
57+ // Open formats
58+ {name: "webm", contentType: "video/webm", want: true},
59+ {name: "webm with codecs", contentType: "video/webm; codecs=\"vp9,opus\"", want: true},
60+ {name: "ogg", contentType: "video/ogg", want: true},
61+
62+ // Proprietary formats (should be false)
63+ {name: "mp4", contentType: "video/mp4", want: false},
64+ {name: "flv", contentType: "video/x-flv", want: false},
65+ {name: "mkv", contentType: "video/x-matroska", want: false},
66+
67+ // Non-video (should be false)
68+ {name: "audio ogg", contentType: "audio/ogg", want: false},
69+ {name: "text html", contentType: "text/html", want: false},
70+ {name: "octet-stream", contentType: "application/octet-stream", want: false},
71+ {name: "empty", contentType: "", want: false},
72+
73+ // Case insensitivity
74+ {name: "webm uppercase", contentType: "Video/WebM", want: true},
75+ {name: "ogg uppercase", contentType: "VIDEO/OGG", want: true},
76+ }
77+
78+ for _, tt := range tests {
79+ t.Run(tt.name, func(t *testing.T) {
80+ got := IsOpenVideoMIME(tt.contentType)
81+ if got != tt.want {
82+ t.Errorf("IsOpenVideoMIME(%q) = %v, want %v", tt.contentType, got, tt.want)
83+ }
84+ })
85+ }
86+}
A internal/infrastructure/transcoder/ffmpeg_offsets.go +168 -0
@@ -0,0 +1,168 @@
1+package transcoder
2+
3+// Offset-based accessors for FFmpeg struct fields not yet exposed
4+// by purego-ffmpeg. These offsets are for FFmpeg 7.x (libavcodec 62,
5+// libavformat 62). Verified against the same headers used by purego-ffmpeg.
6+
7+import "unsafe"
8+
9+// ---------------------------------------------------------------------------
10+// AVPacket field offsets (FFmpeg 7.x)
11+// Layout: AVBufferRef* buf (0), int64 pts (8), int64 dts (16),
12+// uint8* data (24), int size (32), int stream_index (36), int flags (40)
13+// ---------------------------------------------------------------------------
14+
15+const (
16+ offsetPktPts = 8
17+ offsetPktDts = 16
18+ offsetPktStreamIndex = 36
19+)
20+
21+func pktStreamIndex(pkt unsafe.Pointer) int32 {
22+ return *(*int32)(unsafe.Add(pkt, offsetPktStreamIndex))
23+}
24+
25+func pktSetStreamIndex(pkt unsafe.Pointer, idx int32) {
26+ *(*int32)(unsafe.Add(pkt, offsetPktStreamIndex)) = idx
27+}
28+
29+// ---------------------------------------------------------------------------
30+// AVFrame field offsets (FFmpeg 7.x, libavutil 60)
31+// Layout (relevant fields):
32+// data[8] at offset 0 (8 pointers = 64 bytes)
33+// linesize[8] at offset 64 (8 x int32 = 32 bytes)
34+// ...
35+// width at offset 268
36+// height at offset 272
37+// nb_samples at offset 276
38+// format at offset 280
39+// pts at offset 328
40+// ...
41+// sample_rate at offset 432
42+// ch_layout at offset 440 (AVChannelLayout, 24 bytes)
43+// ---------------------------------------------------------------------------
44+
45+const (
46+ offsetFrameWidth = 268
47+ offsetFrameHeight = 272
48+ offsetFrameNbSamples = 276
49+ offsetFrameFormat = 280
50+ offsetFramePts = 328
51+ offsetFrameSampleRate = 432
52+)
53+
54+func frameWidth(f unsafe.Pointer) int32 {
55+ return *(*int32)(unsafe.Add(f, offsetFrameWidth))
56+}
57+
58+func frameSetWidth(f unsafe.Pointer, v int32) {
59+ *(*int32)(unsafe.Add(f, offsetFrameWidth)) = v
60+}
61+
62+func frameHeight(f unsafe.Pointer) int32 {
63+ return *(*int32)(unsafe.Add(f, offsetFrameHeight))
64+}
65+
66+func frameSetHeight(f unsafe.Pointer, v int32) {
67+ *(*int32)(unsafe.Add(f, offsetFrameHeight)) = v
68+}
69+
70+func frameNbSamples(f unsafe.Pointer) int32 {
71+ return *(*int32)(unsafe.Add(f, offsetFrameNbSamples))
72+}
73+
74+func frameSetNbSamples(f unsafe.Pointer, v int32) {
75+ *(*int32)(unsafe.Add(f, offsetFrameNbSamples)) = v
76+}
77+
78+func frameFormat(f unsafe.Pointer) int32 {
79+ return *(*int32)(unsafe.Add(f, offsetFrameFormat))
80+}
81+
82+func frameSetFormat(f unsafe.Pointer, v int32) {
83+ *(*int32)(unsafe.Add(f, offsetFrameFormat)) = v
84+}
85+
86+func framePts(f unsafe.Pointer) int64 {
87+ return *(*int64)(unsafe.Add(f, offsetFramePts))
88+}
89+
90+func frameSetPts(f unsafe.Pointer, v int64) {
91+ *(*int64)(unsafe.Add(f, offsetFramePts)) = v
92+}
93+
94+func frameSampleRate(f unsafe.Pointer) int32 {
95+ return *(*int32)(unsafe.Add(f, offsetFrameSampleRate))
96+}
97+
98+func frameSetSampleRate(f unsafe.Pointer, v int32) {
99+ *(*int32)(unsafe.Add(f, offsetFrameSampleRate)) = v
100+}
101+
102+// ---------------------------------------------------------------------------
103+// AVCodecContext additional offsets
104+// flags at offset 72 (int32, but stored as uint32 in practice)
105+// ---------------------------------------------------------------------------
106+
107+const (
108+ offsetCodecCtxFlags = 72
109+ offsetCodecCtxGopSize = 120
110+ offsetCodecCtxBitRate = 28
111+ offsetCodecCtxChLayout = 368 // AVChannelLayout struct (24 bytes)
112+)
113+
114+// AV_CODEC_FLAG_GLOBAL_HEADER indicates the codec uses global headers
115+// that should be stored in extradata instead of every keyframe.
116+const avCodecFlagGlobalHeader = 1 << 22
117+
118+func codecCtxFlags(ctx unsafe.Pointer) int32 {
119+ return *(*int32)(unsafe.Add(ctx, offsetCodecCtxFlags))
120+}
121+
122+func codecCtxSetFlags(ctx unsafe.Pointer, v int32) {
123+ *(*int32)(unsafe.Add(ctx, offsetCodecCtxFlags)) = v
124+}
125+
126+func codecCtxSetGopSize(ctx unsafe.Pointer, v int32) {
127+ *(*int32)(unsafe.Add(ctx, offsetCodecCtxGopSize)) = v
128+}
129+
130+func codecCtxSetBitRate(ctx unsafe.Pointer, v int64) {
131+ *(*int64)(unsafe.Add(ctx, offsetCodecCtxBitRate)) = v
132+}
133+
134+// chLayoutCopy copies the 24-byte AVChannelLayout from src to dst.
135+// Both must point to the ch_layout field within their respective AVCodecContext.
136+func codecCtxCopyChLayout(dst, src unsafe.Pointer) {
137+ dstLayout := unsafe.Add(dst, offsetCodecCtxChLayout)
138+ srcLayout := unsafe.Add(src, offsetCodecCtxChLayout)
139+ // AVChannelLayout is 24 bytes in FFmpeg 7.x.
140+ copy(
141+ unsafe.Slice((*byte)(dstLayout), 24),
142+ unsafe.Slice((*byte)(srcLayout), 24),
143+ )
144+}
145+
146+// ---------------------------------------------------------------------------
147+// AVCodecParameters additional offsets
148+// ---------------------------------------------------------------------------
149+
150+const (
151+ offsetCodecParSampleRate = 152
152+ offsetCodecParChLayout = 56 // AVChannelLayout (24 bytes) at offset 56
153+)
154+
155+func codecParSampleRate(par unsafe.Pointer) int32 {
156+ return *(*int32)(unsafe.Add(par, offsetCodecParSampleRate))
157+}
158+
159+// codecParCopyChLayoutTo copies the ch_layout from AVCodecParameters to
160+// an AVCodecContext.
161+func codecParCopyChLayoutTo(codecCtx, par unsafe.Pointer) {
162+ dstLayout := unsafe.Add(codecCtx, offsetCodecCtxChLayout)
163+ srcLayout := unsafe.Add(par, offsetCodecParChLayout)
164+ copy(
165+ unsafe.Slice((*byte)(dstLayout), 24),
166+ unsafe.Slice((*byte)(srcLayout), 24),
167+ )
168+}
@@ -0,0 +1,168 @@
1+package transcoder
2+
3+// Offset-based accessors for FFmpeg struct fields not yet exposed
4+// by purego-ffmpeg. These offsets are for FFmpeg 7.x (libavcodec 62,
5+// libavformat 62). Verified against the same headers used by purego-ffmpeg.
6+
7+import "unsafe"
8+
9+// ---------------------------------------------------------------------------
10+// AVPacket field offsets (FFmpeg 7.x)
11+// Layout: AVBufferRef* buf (0), int64 pts (8), int64 dts (16),
12+// uint8* data (24), int size (32), int stream_index (36), int flags (40)
13+// ---------------------------------------------------------------------------
14+
15+const (
16+ offsetPktPts = 8
17+ offsetPktDts = 16
18+ offsetPktStreamIndex = 36
19+)
20+
21+func pktStreamIndex(pkt unsafe.Pointer) int32 {
22+ return *(*int32)(unsafe.Add(pkt, offsetPktStreamIndex))
23+}
24+
25+func pktSetStreamIndex(pkt unsafe.Pointer, idx int32) {
26+ *(*int32)(unsafe.Add(pkt, offsetPktStreamIndex)) = idx
27+}
28+
29+// ---------------------------------------------------------------------------
30+// AVFrame field offsets (FFmpeg 7.x, libavutil 60)
31+// Layout (relevant fields):
32+// data[8] at offset 0 (8 pointers = 64 bytes)
33+// linesize[8] at offset 64 (8 x int32 = 32 bytes)
34+// ...
35+// width at offset 268
36+// height at offset 272
37+// nb_samples at offset 276
38+// format at offset 280
39+// pts at offset 328
40+// ...
41+// sample_rate at offset 432
42+// ch_layout at offset 440 (AVChannelLayout, 24 bytes)
43+// ---------------------------------------------------------------------------
44+
45+const (
46+ offsetFrameWidth = 268
47+ offsetFrameHeight = 272
48+ offsetFrameNbSamples = 276
49+ offsetFrameFormat = 280
50+ offsetFramePts = 328
51+ offsetFrameSampleRate = 432
52+)
53+
54+func frameWidth(f unsafe.Pointer) int32 {
55+ return *(*int32)(unsafe.Add(f, offsetFrameWidth))
56+}
57+
58+func frameSetWidth(f unsafe.Pointer, v int32) {
59+ *(*int32)(unsafe.Add(f, offsetFrameWidth)) = v
60+}
61+
62+func frameHeight(f unsafe.Pointer) int32 {
63+ return *(*int32)(unsafe.Add(f, offsetFrameHeight))
64+}
65+
66+func frameSetHeight(f unsafe.Pointer, v int32) {
67+ *(*int32)(unsafe.Add(f, offsetFrameHeight)) = v
68+}
69+
70+func frameNbSamples(f unsafe.Pointer) int32 {
71+ return *(*int32)(unsafe.Add(f, offsetFrameNbSamples))
72+}
73+
74+func frameSetNbSamples(f unsafe.Pointer, v int32) {
75+ *(*int32)(unsafe.Add(f, offsetFrameNbSamples)) = v
76+}
77+
78+func frameFormat(f unsafe.Pointer) int32 {
79+ return *(*int32)(unsafe.Add(f, offsetFrameFormat))
80+}
81+
82+func frameSetFormat(f unsafe.Pointer, v int32) {
83+ *(*int32)(unsafe.Add(f, offsetFrameFormat)) = v
84+}
85+
86+func framePts(f unsafe.Pointer) int64 {
87+ return *(*int64)(unsafe.Add(f, offsetFramePts))
88+}
89+
90+func frameSetPts(f unsafe.Pointer, v int64) {
91+ *(*int64)(unsafe.Add(f, offsetFramePts)) = v
92+}
93+
94+func frameSampleRate(f unsafe.Pointer) int32 {
95+ return *(*int32)(unsafe.Add(f, offsetFrameSampleRate))
96+}
97+
98+func frameSetSampleRate(f unsafe.Pointer, v int32) {
99+ *(*int32)(unsafe.Add(f, offsetFrameSampleRate)) = v
100+}
101+
102+// ---------------------------------------------------------------------------
103+// AVCodecContext additional offsets
104+// flags at offset 72 (int32, but stored as uint32 in practice)
105+// ---------------------------------------------------------------------------
106+
107+const (
108+ offsetCodecCtxFlags = 72
109+ offsetCodecCtxGopSize = 120
110+ offsetCodecCtxBitRate = 28
111+ offsetCodecCtxChLayout = 368 // AVChannelLayout struct (24 bytes)
112+)
113+
114+// AV_CODEC_FLAG_GLOBAL_HEADER indicates the codec uses global headers
115+// that should be stored in extradata instead of every keyframe.
116+const avCodecFlagGlobalHeader = 1 << 22
117+
118+func codecCtxFlags(ctx unsafe.Pointer) int32 {
119+ return *(*int32)(unsafe.Add(ctx, offsetCodecCtxFlags))
120+}
121+
122+func codecCtxSetFlags(ctx unsafe.Pointer, v int32) {
123+ *(*int32)(unsafe.Add(ctx, offsetCodecCtxFlags)) = v
124+}
125+
126+func codecCtxSetGopSize(ctx unsafe.Pointer, v int32) {
127+ *(*int32)(unsafe.Add(ctx, offsetCodecCtxGopSize)) = v
128+}
129+
130+func codecCtxSetBitRate(ctx unsafe.Pointer, v int64) {
131+ *(*int64)(unsafe.Add(ctx, offsetCodecCtxBitRate)) = v
132+}
133+
134+// chLayoutCopy copies the 24-byte AVChannelLayout from src to dst.
135+// Both must point to the ch_layout field within their respective AVCodecContext.
136+func codecCtxCopyChLayout(dst, src unsafe.Pointer) {
137+ dstLayout := unsafe.Add(dst, offsetCodecCtxChLayout)
138+ srcLayout := unsafe.Add(src, offsetCodecCtxChLayout)
139+ // AVChannelLayout is 24 bytes in FFmpeg 7.x.
140+ copy(
141+ unsafe.Slice((*byte)(dstLayout), 24),
142+ unsafe.Slice((*byte)(srcLayout), 24),
143+ )
144+}
145+
146+// ---------------------------------------------------------------------------
147+// AVCodecParameters additional offsets
148+// ---------------------------------------------------------------------------
149+
150+const (
151+ offsetCodecParSampleRate = 152
152+ offsetCodecParChLayout = 56 // AVChannelLayout (24 bytes) at offset 56
153+)
154+
155+func codecParSampleRate(par unsafe.Pointer) int32 {
156+ return *(*int32)(unsafe.Add(par, offsetCodecParSampleRate))
157+}
158+
159+// codecParCopyChLayoutTo copies the ch_layout from AVCodecParameters to
160+// an AVCodecContext.
161+func codecParCopyChLayoutTo(codecCtx, par unsafe.Pointer) {
162+ dstLayout := unsafe.Add(codecCtx, offsetCodecCtxChLayout)
163+ srcLayout := unsafe.Add(par, offsetCodecParChLayout)
164+ copy(
165+ unsafe.Slice((*byte)(dstLayout), 24),
166+ unsafe.Slice((*byte)(srcLayout), 24),
167+ )
168+}
A internal/infrastructure/transcoder/hwprobe.go +125 -0
@@ -0,0 +1,125 @@
1+package transcoder
2+
3+import (
4+ "unsafe"
5+
6+ "github.com/bnema/purego-ffmpeg/ffmpeg"
7+ "github.com/rs/zerolog"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+)
11+
12+// usableEncoders lists hardware encoders that output open codecs
13+// CEF can decode natively (VP9, AV1). H.264/HEVC encoders exist
14+// but their output cannot be played by CEF's minimal build.
15+var usableEncoders = map[string]bool{
16+ "av1_vaapi": true,
17+ "vp9_vaapi": true,
18+ "av1_nvenc": true,
19+}
20+
21+// isUsableEncoder returns true only for hardware encoders whose output
22+// codec is open and decodable by CEF (AV1, VP9).
23+func isUsableEncoder(name string) bool {
24+ return usableEncoders[name]
25+}
26+
27+// hwProfile groups the encoder/decoder names to probe for a given API.
28+type hwProfile struct {
29+ api string // "vaapi" or "nvenc"
30+ deviceType int32
31+ encoders []string
32+ decoders []string
33+}
34+
35+var vaAPIProfile = hwProfile{
36+ api: "vaapi",
37+ deviceType: ffmpeg.AV_HWDEVICE_TYPE_VAAPI,
38+ encoders: []string{"av1_vaapi", "vp9_vaapi", "h264_vaapi"},
39+ decoders: []string{"h264_vaapi", "hevc_vaapi"},
40+}
41+
42+var nvencProfile = hwProfile{
43+ api: "nvenc",
44+ deviceType: ffmpeg.AV_HWDEVICE_TYPE_CUDA,
45+ encoders: []string{"av1_nvenc", "hevc_nvenc", "h264_nvenc"},
46+ decoders: []string{"h264_cuvid", "hevc_cuvid"},
47+}
48+
49+// ProbeGPU detects available GPU hardware encoding capabilities.
50+// hwaccelPref selects which APIs to try: "auto" (VAAPI then CUDA),
51+// "vaapi" (VAAPI only), or "nvenc" (CUDA only).
52+// Returns empty HWCapabilities if no compatible GPU encoder is found.
53+func ProbeGPU(hwaccelPref string, logger *zerolog.Logger) port.HWCapabilities {
54+ if err := ffmpeg.Init(); err != nil {
55+ logger.Warn().Err(err).Msg("ffmpeg init failed, GPU probe skipped")
56+ return port.HWCapabilities{}
57+ }
58+
59+ var profiles []hwProfile
60+ switch hwaccelPref {
61+ case "vaapi":
62+ profiles = []hwProfile{vaAPIProfile}
63+ case "nvenc":
64+ profiles = []hwProfile{nvencProfile}
65+ default: // "auto" or unrecognised
66+ profiles = []hwProfile{vaAPIProfile, nvencProfile}
67+ }
68+
69+ for _, p := range profiles {
70+ caps := probeProfile(p, logger)
71+ if len(caps.Encoders) > 0 {
72+ return caps
73+ }
74+ }
75+
76+ logger.Info().Msg("no usable GPU encoder found, transcoding will be unavailable")
77+ return port.HWCapabilities{}
78+}
79+
80+// probeProfile attempts to open a hardware device and enumerate the
81+// available encoders/decoders for a single API profile.
82+func probeProfile(p hwProfile, logger *zerolog.Logger) port.HWCapabilities {
83+ deviceCtx, err := ffmpeg.HWDeviceCtxCreate(p.deviceType, "")
84+ if err != nil {
85+ logger.Debug().
86+ Str("api", p.api).
87+ Err(err).
88+ Msg("hardware device not available")
89+ return port.HWCapabilities{}
90+ }
91+ // Free the probing device context; a long-lived one will be
92+ // created per transcode session (or shared across sessions).
93+ defer ffmpeg.BufferUnref(unsafe.Pointer(&deviceCtx))
94+
95+ logger.Info().
96+ Str("api", p.api).
97+ Msg("hardware device opened for probing")
98+
99+ var encoders []string
100+ for _, name := range p.encoders {
101+ if ffmpeg.CodecFindEncoderByName(name) != nil {
102+ if isUsableEncoder(name) {
103+ encoders = append(encoders, name)
104+ logger.Info().Str("encoder", name).Msg("usable GPU encoder found")
105+ } else {
106+ logger.Debug().Str("encoder", name).Msg("encoder present but not usable (proprietary output)")
107+ }
108+ }
109+ }
110+
111+ var decoders []string
112+ for _, name := range p.decoders {
113+ if ffmpeg.CodecFindDecoderByName(name) != nil {
114+ decoders = append(decoders, name)
115+ logger.Debug().Str("decoder", name).Msg("GPU decoder found")
116+ }
117+ }
118+
119+ return port.HWCapabilities{
120+ API: p.api,
121+ Device: "", // auto-detected by FFmpeg; reserved for future multi-GPU pinning
122+ Encoders: encoders,
123+ Decoders: decoders,
124+ }
125+}
@@ -0,0 +1,125 @@
1+package transcoder
2+
3+import (
4+ "unsafe"
5+
6+ "github.com/bnema/purego-ffmpeg/ffmpeg"
7+ "github.com/rs/zerolog"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+)
11+
12+// usableEncoders lists hardware encoders that output open codecs
13+// CEF can decode natively (VP9, AV1). H.264/HEVC encoders exist
14+// but their output cannot be played by CEF's minimal build.
15+var usableEncoders = map[string]bool{
16+ "av1_vaapi": true,
17+ "vp9_vaapi": true,
18+ "av1_nvenc": true,
19+}
20+
21+// isUsableEncoder returns true only for hardware encoders whose output
22+// codec is open and decodable by CEF (AV1, VP9).
23+func isUsableEncoder(name string) bool {
24+ return usableEncoders[name]
25+}
26+
27+// hwProfile groups the encoder/decoder names to probe for a given API.
28+type hwProfile struct {
29+ api string // "vaapi" or "nvenc"
30+ deviceType int32
31+ encoders []string
32+ decoders []string
33+}
34+
35+var vaAPIProfile = hwProfile{
36+ api: "vaapi",
37+ deviceType: ffmpeg.AV_HWDEVICE_TYPE_VAAPI,
38+ encoders: []string{"av1_vaapi", "vp9_vaapi", "h264_vaapi"},
39+ decoders: []string{"h264_vaapi", "hevc_vaapi"},
40+}
41+
42+var nvencProfile = hwProfile{
43+ api: "nvenc",
44+ deviceType: ffmpeg.AV_HWDEVICE_TYPE_CUDA,
45+ encoders: []string{"av1_nvenc", "hevc_nvenc", "h264_nvenc"},
46+ decoders: []string{"h264_cuvid", "hevc_cuvid"},
47+}
48+
49+// ProbeGPU detects available GPU hardware encoding capabilities.
50+// hwaccelPref selects which APIs to try: "auto" (VAAPI then CUDA),
51+// "vaapi" (VAAPI only), or "nvenc" (CUDA only).
52+// Returns empty HWCapabilities if no compatible GPU encoder is found.
53+func ProbeGPU(hwaccelPref string, logger *zerolog.Logger) port.HWCapabilities {
54+ if err := ffmpeg.Init(); err != nil {
55+ logger.Warn().Err(err).Msg("ffmpeg init failed, GPU probe skipped")
56+ return port.HWCapabilities{}
57+ }
58+
59+ var profiles []hwProfile
60+ switch hwaccelPref {
61+ case "vaapi":
62+ profiles = []hwProfile{vaAPIProfile}
63+ case "nvenc":
64+ profiles = []hwProfile{nvencProfile}
65+ default: // "auto" or unrecognised
66+ profiles = []hwProfile{vaAPIProfile, nvencProfile}
67+ }
68+
69+ for _, p := range profiles {
70+ caps := probeProfile(p, logger)
71+ if len(caps.Encoders) > 0 {
72+ return caps
73+ }
74+ }
75+
76+ logger.Info().Msg("no usable GPU encoder found, transcoding will be unavailable")
77+ return port.HWCapabilities{}
78+}
79+
80+// probeProfile attempts to open a hardware device and enumerate the
81+// available encoders/decoders for a single API profile.
82+func probeProfile(p hwProfile, logger *zerolog.Logger) port.HWCapabilities {
83+ deviceCtx, err := ffmpeg.HWDeviceCtxCreate(p.deviceType, "")
84+ if err != nil {
85+ logger.Debug().
86+ Str("api", p.api).
87+ Err(err).
88+ Msg("hardware device not available")
89+ return port.HWCapabilities{}
90+ }
91+ // Free the probing device context; a long-lived one will be
92+ // created per transcode session (or shared across sessions).
93+ defer ffmpeg.BufferUnref(unsafe.Pointer(&deviceCtx))
94+
95+ logger.Info().
96+ Str("api", p.api).
97+ Msg("hardware device opened for probing")
98+
99+ var encoders []string
100+ for _, name := range p.encoders {
101+ if ffmpeg.CodecFindEncoderByName(name) != nil {
102+ if isUsableEncoder(name) {
103+ encoders = append(encoders, name)
104+ logger.Info().Str("encoder", name).Msg("usable GPU encoder found")
105+ } else {
106+ logger.Debug().Str("encoder", name).Msg("encoder present but not usable (proprietary output)")
107+ }
108+ }
109+ }
110+
111+ var decoders []string
112+ for _, name := range p.decoders {
113+ if ffmpeg.CodecFindDecoderByName(name) != nil {
114+ decoders = append(decoders, name)
115+ logger.Debug().Str("decoder", name).Msg("GPU decoder found")
116+ }
117+ }
118+
119+ return port.HWCapabilities{
120+ API: p.api,
121+ Device: "", // auto-detected by FFmpeg; reserved for future multi-GPU pinning
122+ Encoders: encoders,
123+ Decoders: decoders,
124+ }
125+}
A internal/infrastructure/transcoder/hwprobe_test.go +39 -0
@@ -0,0 +1,39 @@
1+package transcoder
2+
3+import "testing"
4+
5+func TestIsUsableEncoder(t *testing.T) {
6+ tests := []struct {
7+ name string
8+ encoder string
9+ want bool
10+ }{
11+ // Usable: open codec output that CEF can decode
12+ {name: "av1_vaapi", encoder: "av1_vaapi", want: true},
13+ {name: "vp9_vaapi", encoder: "vp9_vaapi", want: true},
14+ {name: "av1_nvenc", encoder: "av1_nvenc", want: true},
15+
16+ // Not usable: proprietary codec output CEF cannot decode
17+ {name: "h264_vaapi", encoder: "h264_vaapi", want: false},
18+ {name: "hevc_nvenc", encoder: "hevc_nvenc", want: false},
19+ {name: "h264_nvenc", encoder: "h264_nvenc", want: false},
20+
21+ // Software encoders are not usable either
22+ {name: "libx264", encoder: "libx264", want: false},
23+ {name: "libsvtav1", encoder: "libsvtav1", want: false},
24+ {name: "libvpx-vp9", encoder: "libvpx-vp9", want: false},
25+
26+ // Edge cases
27+ {name: "empty string", encoder: "", want: false},
28+ {name: "random string", encoder: "not_a_codec", want: false},
29+ }
30+
31+ for _, tt := range tests {
32+ t.Run(tt.name, func(t *testing.T) {
33+ got := isUsableEncoder(tt.encoder)
34+ if got != tt.want {
35+ t.Errorf("isUsableEncoder(%q) = %v, want %v", tt.encoder, got, tt.want)
36+ }
37+ })
38+ }
39+}
@@ -0,0 +1,39 @@
1+package transcoder
2+
3+import "testing"
4+
5+func TestIsUsableEncoder(t *testing.T) {
6+ tests := []struct {
7+ name string
8+ encoder string
9+ want bool
10+ }{
11+ // Usable: open codec output that CEF can decode
12+ {name: "av1_vaapi", encoder: "av1_vaapi", want: true},
13+ {name: "vp9_vaapi", encoder: "vp9_vaapi", want: true},
14+ {name: "av1_nvenc", encoder: "av1_nvenc", want: true},
15+
16+ // Not usable: proprietary codec output CEF cannot decode
17+ {name: "h264_vaapi", encoder: "h264_vaapi", want: false},
18+ {name: "hevc_nvenc", encoder: "hevc_nvenc", want: false},
19+ {name: "h264_nvenc", encoder: "h264_nvenc", want: false},
20+
21+ // Software encoders are not usable either
22+ {name: "libx264", encoder: "libx264", want: false},
23+ {name: "libsvtav1", encoder: "libsvtav1", want: false},
24+ {name: "libvpx-vp9", encoder: "libvpx-vp9", want: false},
25+
26+ // Edge cases
27+ {name: "empty string", encoder: "", want: false},
28+ {name: "random string", encoder: "not_a_codec", want: false},
29+ }
30+
31+ for _, tt := range tests {
32+ t.Run(tt.name, func(t *testing.T) {
33+ got := isUsableEncoder(tt.encoder)
34+ if got != tt.want {
35+ t.Errorf("isUsableEncoder(%q) = %v, want %v", tt.encoder, got, tt.want)
36+ }
37+ })
38+ }
39+}
A internal/infrastructure/transcoder/pipeline.go +601 -0
@@ -0,0 +1,601 @@
1+package transcoder
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "io"
8+ "net/http"
9+ "strings"
10+ "unsafe"
11+
12+ "github.com/ebitengine/purego"
13+
14+ "github.com/bnema/purego-ffmpeg/ffmpeg"
15+
16+ "github.com/bnema/dumber/internal/application/port"
17+)
18+
19+// avioBufSize is the buffer size for custom AVIO contexts (32 KiB).
20+const avioBufSize = 32 * 1024
21+
22+// pipeline orchestrates a single GPU transcode session. It reads from
23+// an HTTP source, decodes with a hardware decoder (or software fallback),
24+// encodes video to an open codec (AV1/VP9) via GPU, encodes audio to
25+// Opus via libopus, and muxes the result into WebM written to an
26+// io.PipeWriter.
27+type pipeline struct {
28+ hwCaps port.HWCapabilities
29+ sourceURL string
30+ headers map[string]string
31+ quality string
32+ pw *io.PipeWriter
33+ cancel context.CancelFunc
34+
35+ // Prevent GC of callback closures and uintptrs.
36+ readCb uintptr
37+ writeCb uintptr
38+}
39+
40+func newPipeline(hwCaps port.HWCapabilities, sourceURL string, headers map[string]string, quality string, pw *io.PipeWriter) *pipeline {
41+ return &pipeline{
42+ hwCaps: hwCaps,
43+ sourceURL: sourceURL,
44+ headers: headers,
45+ quality: quality,
46+ pw: pw,
47+ }
48+}
49+
50+// run executes the full transcode pipeline in the calling goroutine.
51+// It closes pw when done (with error or nil).
52+func (p *pipeline) run(ctx context.Context) {
53+ err := p.doRun(ctx)
54+ if err != nil {
55+ p.pw.CloseWithError(err)
56+ } else {
57+ p.pw.Close()
58+ }
59+}
60+
61+// doRun contains the actual pipeline logic. Errors are returned, and
62+// the caller (run) closes the pipe writer accordingly.
63+func (p *pipeline) doRun(ctx context.Context) error {
64+ // --- Step 1: HTTP GET the source ---
65+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.sourceURL, nil)
66+ if err != nil {
67+ return fmt.Errorf("create request: %w", err)
68+ }
69+ for k, v := range p.headers {
70+ req.Header.Set(k, v)
71+ }
72+
73+ resp, err := http.DefaultClient.Do(req)
74+ if err != nil {
75+ return fmt.Errorf("fetch source: %w", err)
76+ }
77+ defer resp.Body.Close()
78+
79+ if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent {
80+ return fmt.Errorf("source returned HTTP %d", resp.StatusCode)
81+ }
82+
83+ // --- Step 2: Create read AVIO callback from HTTP body ---
84+ body := resp.Body
85+ readFn := func(_ unsafe.Pointer, buf unsafe.Pointer, bufSize int32) int32 {
86+ if bufSize <= 0 {
87+ return -1
88+ }
89+ goBuf := unsafe.Slice((*byte)(buf), int(bufSize))
90+ n, readErr := body.Read(goBuf)
91+ if n > 0 {
92+ return int32(n)
93+ }
94+ if readErr != nil {
95+ // Return AVERROR_EOF for EOF, generic error otherwise.
96+ return averrorEOF
97+ }
98+ return -1
99+ }
100+ p.readCb = purego.NewCallback(readFn)
101+
102+ readAVIO := ffmpeg.AvioAllocContext(avioBufSize, false, nil, p.readCb, 0, 0)
103+ if readAVIO == nil {
104+ return errors.New("failed to allocate read AVIO context")
105+ }
106+ defer ffmpeg.AvioContextFree(unsafe.Pointer(&readAVIO))
107+
108+ // --- Step 3: Open input format context with custom AVIO ---
109+ inFmtCtx := ffmpeg.FormatAllocContext()
110+ if inFmtCtx == nil {
111+ return errors.New("failed to allocate input format context")
112+ }
113+
114+ ffmpeg.FmtCtxSetPB(inFmtCtx, readAVIO)
115+ ffmpeg.FmtCtxSetFlags(inFmtCtx, ffmpeg.FmtCtxFlags(inFmtCtx)|ffmpeg.AVFMT_FLAG_CUSTOM_IO)
116+
117+ if ret := ffmpeg.FormatOpenInput(unsafe.Pointer(&inFmtCtx), "", nil, nil); ret < 0 {
118+ ffmpeg.FormatFreeContext(inFmtCtx)
119+ return fmt.Errorf("open input: %d", ret)
120+ }
121+ defer ffmpeg.FormatCloseInput(unsafe.Pointer(&inFmtCtx))
122+
123+ // --- Step 4: Find stream info ---
124+ if ret := ffmpeg.FormatFindStreamInfo(inFmtCtx, nil); ret < 0 {
125+ return fmt.Errorf("find stream info: %d", ret)
126+ }
127+
128+ // --- Step 5: Find best video and audio streams ---
129+ videoIdx := ffmpeg.FindBestStream(inFmtCtx, int32(ffmpeg.AVMEDIA_TYPE_VIDEO), -1, -1, nil, 0)
130+ audioIdx := ffmpeg.FindBestStream(inFmtCtx, int32(ffmpeg.AVMEDIA_TYPE_AUDIO), -1, -1, nil, 0)
131+
132+ if videoIdx < 0 {
133+ return errors.New("no video stream found in source")
134+ }
135+
136+ videoStream := ffmpeg.FmtCtxStream(inFmtCtx, int(videoIdx))
137+ videoStreamWrap := (*ffmpeg.Stream)(videoStream)
138+ videoCodecPar := videoStreamWrap.Codecpar()
139+
140+ // --- Step 6: Open video decoder ---
141+ vidDecCtx, err := p.openVideoDecoder(videoCodecPar)
142+ if err != nil {
143+ return fmt.Errorf("open video decoder: %w", err)
144+ }
145+ defer ffmpeg.CodecFreeContext(unsafe.Pointer(&vidDecCtx))
146+
147+ // --- Step 7-8: Open video encoder with HW accel ---
148+ hwDeviceCtx, vidEncCtx, err := p.openVideoEncoder(vidDecCtx)
149+ if err != nil {
150+ return fmt.Errorf("open video encoder: %w", err)
151+ }
152+ if hwDeviceCtx != nil {
153+ defer ffmpeg.BufferUnref(unsafe.Pointer(&hwDeviceCtx))
154+ }
155+ defer ffmpeg.CodecFreeContext(unsafe.Pointer(&vidEncCtx))
156+
157+ // --- Step 9: Create output format context for WebM ---
158+ var outFmtCtx unsafe.Pointer
159+ if ret := ffmpeg.FormatAllocOutputContext2(unsafe.Pointer(&outFmtCtx), nil, "webm", ""); ret < 0 {
160+ return fmt.Errorf("alloc output context: %d", ret)
161+ }
162+ defer ffmpeg.FormatFreeContext(outFmtCtx)
163+
164+ // --- Step 10: Create write AVIO callback ---
165+ pw := p.pw
166+ writeFn := func(_ unsafe.Pointer, buf unsafe.Pointer, bufSize int32) int32 {
167+ if bufSize <= 0 {
168+ return 0
169+ }
170+ goBuf := unsafe.Slice((*byte)(buf), int(bufSize))
171+ n, writeErr := pw.Write(goBuf)
172+ if writeErr != nil {
173+ return -1
174+ }
175+ return int32(n)
176+ }
177+ p.writeCb = purego.NewCallback(writeFn)
178+
179+ writeAVIO := ffmpeg.AvioAllocContext(avioBufSize, true, nil, 0, p.writeCb, 0)
180+ if writeAVIO == nil {
181+ return errors.New("failed to allocate write AVIO context")
182+ }
183+ defer ffmpeg.AvioContextFree(unsafe.Pointer(&writeAVIO))
184+
185+ ffmpeg.FmtCtxSetPB(outFmtCtx, writeAVIO)
186+ ffmpeg.FmtCtxSetFlags(outFmtCtx, ffmpeg.FmtCtxFlags(outFmtCtx)|ffmpeg.AVFMT_FLAG_CUSTOM_IO)
187+
188+ // --- Step 11: Add video output stream ---
189+ outVideoStream := ffmpeg.FormatNewStream(outFmtCtx, nil)
190+ if outVideoStream == nil {
191+ return errors.New("failed to create output video stream")
192+ }
193+ outVideoStreamWrap := (*ffmpeg.Stream)(outVideoStream)
194+ outVideoIdx := int(outVideoStreamWrap.Index())
195+
196+ if ret := ffmpeg.CodecParametersFromContext(outVideoStreamWrap.Codecpar(), vidEncCtx); ret < 0 {
197+ return fmt.Errorf("copy video encoder params to stream: %d", ret)
198+ }
199+
200+ // --- Step 11b: Add audio output stream (if audio present) ---
201+ var audioTx *audioTranscoder
202+ if audioIdx >= 0 {
203+ audioStream := ffmpeg.FmtCtxStream(inFmtCtx, int(audioIdx))
204+ audioStreamWrap := (*ffmpeg.Stream)(audioStream)
205+ audioCodecPar := audioStreamWrap.Codecpar()
206+
207+ var txErr error
208+ audioTx, txErr = newAudioTranscoder(audioCodecPar, outFmtCtx, int(audioIdx))
209+ if txErr != nil {
210+ // Audio transcoding failure is non-fatal; we proceed with video only.
211+ audioTx = nil
212+ audioIdx = -1
213+ }
214+ }
215+ if audioTx != nil {
216+ defer audioTx.close()
217+ }
218+
219+ // --- Step 12: Write header ---
220+ if ret := ffmpeg.FormatWriteHeader(outFmtCtx, nil); ret < 0 {
221+ return fmt.Errorf("write header: %d", ret)
222+ }
223+
224+ // --- Step 13: Main decode/encode loop ---
225+ pkt := ffmpeg.PacketAlloc()
226+ if pkt == nil {
227+ return errors.New("failed to allocate read packet")
228+ }
229+ defer ffmpeg.PacketFree(unsafe.Pointer(&pkt))
230+
231+ frame := ffmpeg.FrameAlloc()
232+ if frame == nil {
233+ return errors.New("failed to allocate decode frame")
234+ }
235+ defer ffmpeg.FrameFree(unsafe.Pointer(&frame))
236+
237+ encPkt := ffmpeg.PacketAlloc()
238+ if encPkt == nil {
239+ return errors.New("failed to allocate encode packet")
240+ }
241+ defer ffmpeg.PacketFree(unsafe.Pointer(&encPkt))
242+
243+ for {
244+ // Check context cancellation.
245+ select {
246+ case <-ctx.Done():
247+ return ctx.Err()
248+ default:
249+ }
250+
251+ ret := ffmpeg.ReadFrame(inFmtCtx, pkt)
252+ if ret < 0 {
253+ // EOF or error — break to flush.
254+ break
255+ }
256+
257+ streamIdx := pktStreamIndex(pkt)
258+
259+ switch {
260+ case streamIdx == videoIdx:
261+ if err := p.transcodeVideoPacket(pkt, frame, encPkt, vidDecCtx, vidEncCtx, outFmtCtx, videoStreamWrap, outVideoIdx); err != nil {
262+ ffmpeg.PacketUnref(pkt)
263+ return fmt.Errorf("video transcode: %w", err)
264+ }
265+
266+ case audioTx != nil && streamIdx == audioIdx:
267+ if err := audioTx.processPacket(pkt, outFmtCtx); err != nil {
268+ ffmpeg.PacketUnref(pkt)
269+ return fmt.Errorf("audio transcode: %w", err)
270+ }
271+ }
272+
273+ ffmpeg.PacketUnref(pkt)
274+ }
275+
276+ // --- Step 14: Flush decoders and encoders ---
277+ // Flush video decoder.
278+ ffmpeg.CodecSendPacket(vidDecCtx, nil)
279+ for {
280+ ret := ffmpeg.CodecReceiveFrame(vidDecCtx, frame)
281+ if ret < 0 {
282+ break
283+ }
284+ p.encodeAndWriteVideoFrame(frame, encPkt, vidEncCtx, outFmtCtx, videoStreamWrap, outVideoIdx)
285+ ffmpeg.FrameUnref(frame)
286+ }
287+
288+ // Flush video encoder.
289+ ffmpeg.CodecSendFrame(vidEncCtx, nil)
290+ for {
291+ ret := ffmpeg.CodecReceivePacket(vidEncCtx, encPkt)
292+ if ret < 0 {
293+ break
294+ }
295+ pktSetStreamIndex(encPkt, int32(outVideoIdx))
296+ outVidStream := ffmpeg.FmtCtxStream(outFmtCtx, outVideoIdx)
297+ outVidStreamWrap := (*ffmpeg.Stream)(outVidStream)
298+ ffmpeg.PacketRescaleTs(encPkt, ffmpeg.CodecCtxTimeBase(vidEncCtx), outVidStreamWrap.TimeBase())
299+ ffmpeg.InterleavedWriteFrame(outFmtCtx, encPkt)
300+ ffmpeg.PacketUnref(encPkt)
301+ }
302+
303+ // Flush audio.
304+ if audioTx != nil {
305+ audioTx.flush(outFmtCtx)
306+ }
307+
308+ // --- Step 15: Write trailer ---
309+ if ret := ffmpeg.WriteTrailer(outFmtCtx); ret < 0 {
310+ return fmt.Errorf("write trailer: %d", ret)
311+ }
312+
313+ return nil
314+}
315+
316+// openVideoDecoder creates and opens a decoder context for the video stream.
317+// It prefers hardware decoders (h264_vaapi, h264_cuvid) based on hwCaps,
318+// falling back to the generic software decoder.
319+func (p *pipeline) openVideoDecoder(codecPar unsafe.Pointer) (unsafe.Pointer, error) {
320+ codecID := ffmpeg.CodecParCodecID(codecPar)
321+
322+ // Try HW decoder first.
323+ var dec unsafe.Pointer
324+ for _, name := range p.hwCaps.Decoders {
325+ dec = ffmpeg.CodecFindDecoderByName(name)
326+ if dec != nil {
327+ break
328+ }
329+ }
330+
331+ // Fallback to software decoder.
332+ if dec == nil {
333+ dec = ffmpeg.CodecFindDecoder(codecID)
334+ }
335+ if dec == nil {
336+ return nil, fmt.Errorf("no decoder found for codec ID %d", codecID)
337+ }
338+
339+ decCtx := ffmpeg.CodecAllocContext3(dec)
340+ if decCtx == nil {
341+ return nil, errors.New("failed to allocate video decoder context")
342+ }
343+
344+ if ret := ffmpeg.CodecParametersToContext(decCtx, codecPar); ret < 0 {
345+ ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx))
346+ return nil, fmt.Errorf("copy video codec params: %d", ret)
347+ }
348+
349+ if ret := ffmpeg.CodecOpen2(decCtx, dec, nil); ret < 0 {
350+ ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx))
351+ return nil, fmt.Errorf("open video decoder: %d", ret)
352+ }
353+
354+ return decCtx, nil
355+}
356+
357+// openVideoEncoder creates a hardware device context and encoder for
358+// the video stream. It uses the first available encoder from hwCaps.Encoders.
359+func (p *pipeline) openVideoEncoder(decCtx unsafe.Pointer) (hwDeviceCtx unsafe.Pointer, encCtx unsafe.Pointer, err error) {
360+ if len(p.hwCaps.Encoders) == 0 {
361+ return nil, nil, errors.New("no GPU encoders available")
362+ }
363+
364+ // Create the HW device context.
365+ var deviceType int32
366+ switch p.hwCaps.API {
367+ case "vaapi":
368+ deviceType = ffmpeg.AV_HWDEVICE_TYPE_VAAPI
369+ case "nvenc":
370+ deviceType = ffmpeg.AV_HWDEVICE_TYPE_CUDA
371+ default:
372+ return nil, nil, fmt.Errorf("unknown HW API: %s", p.hwCaps.API)
373+ }
374+
375+ hwDeviceCtx, hwErr := ffmpeg.HWDeviceCtxCreate(deviceType, p.hwCaps.Device)
376+ if hwErr != nil {
377+ return nil, nil, fmt.Errorf("create HW device: %w", hwErr)
378+ }
379+
380+ // Find the first working encoder.
381+ var enc unsafe.Pointer
382+ var encoderName string
383+ for _, name := range p.hwCaps.Encoders {
384+ enc = ffmpeg.CodecFindEncoderByName(name)
385+ if enc != nil {
386+ encoderName = name
387+ break
388+ }
389+ }
390+ if enc == nil {
391+ ffmpeg.BufferUnref(unsafe.Pointer(&hwDeviceCtx))
392+ return nil, nil, errors.New("no usable encoder codec found")
393+ }
394+
395+ encCtx = ffmpeg.CodecAllocContext3(enc)
396+ if encCtx == nil {
397+ ffmpeg.BufferUnref(unsafe.Pointer(&hwDeviceCtx))
398+ return nil, nil, errors.New("failed to allocate video encoder context")
399+ }
400+
401+ // Copy dimensions and framerate from decoder.
402+ width := ffmpeg.CodecCtxWidth(decCtx)
403+ height := ffmpeg.CodecCtxHeight(decCtx)
404+ ffmpeg.CodecCtxSetWidth(encCtx, width)
405+ ffmpeg.CodecCtxSetHeight(encCtx, height)
406+
407+ framerate := ffmpeg.CodecCtxFramerate(decCtx)
408+ if framerate.Num == 0 {
409+ framerate = ffmpeg.AVRational{Num: 30, Den: 1}
410+ }
411+ ffmpeg.CodecCtxSetFramerate(encCtx, framerate)
412+ ffmpeg.CodecCtxSetTimeBase(encCtx, ffmpeg.AVRational{Num: framerate.Den, Den: framerate.Num})
413+
414+ // Set pixel format based on the API.
415+ if p.hwCaps.API == "vaapi" {
416+ ffmpeg.CodecCtxSetPixFmt(encCtx, int32(ffmpeg.PixelFormatPixFmtVaapi))
417+ } else {
418+ // CUDA/NVENC uses NV12.
419+ ffmpeg.CodecCtxSetPixFmt(encCtx, int32(ffmpeg.PixelFormatPixFmtNv12))
420+ }
421+
422+ // Set GOP size to 2 seconds worth of frames.
423+ gopSize := int32(framerate.Num / framerate.Den * 2)
424+ if gopSize <= 0 {
425+ gopSize = 60
426+ }
427+ codecCtxSetGopSize(encCtx, gopSize)
428+
429+ // Set quality/bitrate based on quality parameter.
430+ p.applyQualityPreset(encCtx, encoderName, width, height)
431+
432+ // Attach HW device context. Use BufferRef to create a new reference
433+ // since the encoder will take ownership of one ref.
434+ hwDeviceRef := ffmpeg.BufferRef(hwDeviceCtx)
435+ if hwDeviceRef == nil {
436+ ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx))
437+ ffmpeg.BufferUnref(unsafe.Pointer(&hwDeviceCtx))
438+ return nil, nil, errors.New("failed to ref HW device context for encoder")
439+ }
440+ ffmpeg.CodecCtxSetHwDeviceCtx(encCtx, hwDeviceRef)
441+
442+ // For VAAPI encoders, we need a HW frames context.
443+ if p.hwCaps.API == "vaapi" {
444+ framesRef := ffmpeg.HWFrameCtxAlloc(hwDeviceCtx)
445+ if framesRef == nil {
446+ ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx))
447+ ffmpeg.BufferUnref(unsafe.Pointer(&hwDeviceCtx))
448+ return nil, nil, errors.New("failed to allocate HW frames context")
449+ }
450+
451+ // Configure the frames context. The HWFramesContext struct starts
452+ // with an AVBufferRef header. The actual AVHWFramesContext data is
453+ // accessible after dereferencing. We configure via av_opt or direct
454+ // offset access. For simplicity, use the known offsets:
455+ // AVHWFramesContext (within the AVBufferRef->data):
456+ // format (sw_format) at offset 8, width at 12, height at 16,
457+ // sw_format at 20, initial_pool_size at 24.
458+ // Actually, the buffer ref data pointer leads to AVHWFramesContext.
459+ // The fields are: AVClass* (8), AVBufferRef* device_ref (8),
460+ // AVBufferPool* pool (8), int initial_pool_size (4),
461+ // enum AVPixelFormat format (4), enum AVPixelFormat sw_format (4),
462+ // int width (4), int height (4).
463+ // So from the data pointer:
464+ // offset 24: initial_pool_size (int32)
465+ // offset 28: format (int32) — the HW pixel format
466+ // offset 32: sw_format (int32) — the SW pixel format
467+ // offset 36: width (int32)
468+ // offset 40: height (int32)
469+ //
470+ // We access data via the AVBufferRef at offset 0 (data field at offset 0).
471+ hwFramesData := *(*unsafe.Pointer)(framesRef) // AVBufferRef->data
472+ if hwFramesData != nil {
473+ *(*int32)(unsafe.Add(hwFramesData, 24)) = 20 // initial_pool_size
474+ *(*int32)(unsafe.Add(hwFramesData, 28)) = int32(ffmpeg.PixelFormatPixFmtVaapi)
475+ *(*int32)(unsafe.Add(hwFramesData, 32)) = int32(ffmpeg.PixelFormatPixFmtNv12) // sw_format
476+ *(*int32)(unsafe.Add(hwFramesData, 36)) = width
477+ *(*int32)(unsafe.Add(hwFramesData, 40)) = height
478+ }
479+
480+ if ret := ffmpeg.HWFrameCtxInit(framesRef); ret < 0 {
481+ ffmpeg.BufferUnref(unsafe.Pointer(&framesRef))
482+ ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx))
483+ ffmpeg.BufferUnref(unsafe.Pointer(&hwDeviceCtx))
484+ return nil, nil, fmt.Errorf("init HW frames context: %d", ret)
485+ }
486+
487+ ffmpeg.CodecCtxSetHwFramesCtx(encCtx, framesRef)
488+ }
489+
490+ if ret := ffmpeg.CodecOpen2(encCtx, enc, nil); ret < 0 {
491+ ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx))
492+ ffmpeg.BufferUnref(unsafe.Pointer(&hwDeviceCtx))
493+ return nil, nil, fmt.Errorf("open video encoder %s: %d", encoderName, ret)
494+ }
495+
496+ return hwDeviceCtx, encCtx, nil
497+}
498+
499+// applyQualityPreset sets bitrate and encoder options based on the
500+// quality string ("low", "medium", "high").
501+func (p *pipeline) applyQualityPreset(encCtx unsafe.Pointer, encoderName string, width, height int32) {
502+ pixels := int64(width) * int64(height)
503+
504+ // Base bitrate scaled to resolution (targeting 1080p baseline).
505+ // low: ~1.5 Mbps at 1080p, medium: ~3 Mbps, high: ~6 Mbps.
506+ var bitsPerPixel float64
507+ switch p.quality {
508+ case "low":
509+ bitsPerPixel = 0.07
510+ case "high":
511+ bitsPerPixel = 0.30
512+ default: // "medium"
513+ bitsPerPixel = 0.15
514+ }
515+
516+ bitrate := int64(float64(pixels) * bitsPerPixel)
517+ if bitrate < 500_000 {
518+ bitrate = 500_000 // floor at 500 kbps
519+ }
520+ codecCtxSetBitRate(encCtx, bitrate)
521+
522+ // Set encoder-specific options via av_opt_set.
523+ if strings.HasSuffix(encoderName, "_vaapi") {
524+ // VAAPI encoders use rc_mode for rate control.
525+ ffmpeg.AVOptSet(encCtx, "rc_mode", "VBR", 0)
526+ } else if strings.HasSuffix(encoderName, "_nvenc") {
527+ // NVENC uses preset and rc.
528+ switch p.quality {
529+ case "low":
530+ ffmpeg.AVOptSet(encCtx, "preset", "p4", 0)
531+ case "high":
532+ ffmpeg.AVOptSet(encCtx, "preset", "p7", 0)
533+ default:
534+ ffmpeg.AVOptSet(encCtx, "preset", "p5", 0)
535+ }
536+ ffmpeg.AVOptSet(encCtx, "rc", "vbr", 0)
537+ }
538+}
539+
540+// transcodeVideoPacket decodes a video packet, encodes the resulting
541+// frames, and writes them to the output.
542+func (p *pipeline) transcodeVideoPacket(pkt, frame, encPkt, decCtx, encCtx, outFmtCtx unsafe.Pointer, inStreamWrap *ffmpeg.Stream, outVideoIdx int) error {
543+ ret := ffmpeg.CodecSendPacket(decCtx, pkt)
544+ if ret < 0 {
545+ return fmt.Errorf("video decode send packet: %d", ret)
546+ }
547+
548+ for {
549+ ret = ffmpeg.CodecReceiveFrame(decCtx, frame)
550+ if ret < 0 {
551+ // EAGAIN or EOF.
552+ break
553+ }
554+
555+ if err := p.encodeAndWriteVideoFrame(frame, encPkt, encCtx, outFmtCtx, inStreamWrap, outVideoIdx); err != nil {
556+ ffmpeg.FrameUnref(frame)
557+ return err
558+ }
559+ ffmpeg.FrameUnref(frame)
560+ }
561+
562+ return nil
563+}
564+
565+// encodeAndWriteVideoFrame sends a decoded frame to the encoder and
566+// writes any resulting packets to the output.
567+func (p *pipeline) encodeAndWriteVideoFrame(frame, encPkt, encCtx, outFmtCtx unsafe.Pointer, inStreamWrap *ffmpeg.Stream, outVideoIdx int) error {
568+ // If the frame is in SW format and the encoder expects HW format,
569+ // we would need to upload. For now, send directly — the HW encoder
570+ // with hw_device_ctx set should handle format conversion internally
571+ // when the decoder outputs SW frames.
572+
573+ ret := ffmpeg.CodecSendFrame(encCtx, frame)
574+ if ret < 0 {
575+ return fmt.Errorf("video encode send frame: %d", ret)
576+ }
577+
578+ for {
579+ ret = ffmpeg.CodecReceivePacket(encCtx, encPkt)
580+ if ret < 0 {
581+ break
582+ }
583+
584+ pktSetStreamIndex(encPkt, int32(outVideoIdx))
585+
586+ outVidStream := ffmpeg.FmtCtxStream(outFmtCtx, outVideoIdx)
587+ outVidStreamWrap := (*ffmpeg.Stream)(outVidStream)
588+ ffmpeg.PacketRescaleTs(encPkt, ffmpeg.CodecCtxTimeBase(encCtx), outVidStreamWrap.TimeBase())
589+
590+ if wret := ffmpeg.InterleavedWriteFrame(outFmtCtx, encPkt); wret < 0 {
591+ ffmpeg.PacketUnref(encPkt)
592+ return fmt.Errorf("video write frame: %d", wret)
593+ }
594+ ffmpeg.PacketUnref(encPkt)
595+ }
596+
597+ return nil
598+}
599+
600+// averrorEOF is the AVERROR_EOF constant.
601+const averrorEOF = -('E' | 'O'<<8 | 'F'<<16 | ' '<<24)
@@ -0,0 +1,601 @@
1+package transcoder
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "io"
8+ "net/http"
9+ "strings"
10+ "unsafe"
11+
12+ "github.com/ebitengine/purego"
13+
14+ "github.com/bnema/purego-ffmpeg/ffmpeg"
15+
16+ "github.com/bnema/dumber/internal/application/port"
17+)
18+
19+// avioBufSize is the buffer size for custom AVIO contexts (32 KiB).
20+const avioBufSize = 32 * 1024
21+
22+// pipeline orchestrates a single GPU transcode session. It reads from
23+// an HTTP source, decodes with a hardware decoder (or software fallback),
24+// encodes video to an open codec (AV1/VP9) via GPU, encodes audio to
25+// Opus via libopus, and muxes the result into WebM written to an
26+// io.PipeWriter.
27+type pipeline struct {
28+ hwCaps port.HWCapabilities
29+ sourceURL string
30+ headers map[string]string
31+ quality string
32+ pw *io.PipeWriter
33+ cancel context.CancelFunc
34+
35+ // Prevent GC of callback closures and uintptrs.
36+ readCb uintptr
37+ writeCb uintptr
38+}
39+
40+func newPipeline(hwCaps port.HWCapabilities, sourceURL string, headers map[string]string, quality string, pw *io.PipeWriter) *pipeline {
41+ return &pipeline{
42+ hwCaps: hwCaps,
43+ sourceURL: sourceURL,
44+ headers: headers,
45+ quality: quality,
46+ pw: pw,
47+ }
48+}
49+
50+// run executes the full transcode pipeline in the calling goroutine.
51+// It closes pw when done (with error or nil).
52+func (p *pipeline) run(ctx context.Context) {
53+ err := p.doRun(ctx)
54+ if err != nil {
55+ p.pw.CloseWithError(err)
56+ } else {
57+ p.pw.Close()
58+ }
59+}
60+
61+// doRun contains the actual pipeline logic. Errors are returned, and
62+// the caller (run) closes the pipe writer accordingly.
63+func (p *pipeline) doRun(ctx context.Context) error {
64+ // --- Step 1: HTTP GET the source ---
65+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.sourceURL, nil)
66+ if err != nil {
67+ return fmt.Errorf("create request: %w", err)
68+ }
69+ for k, v := range p.headers {
70+ req.Header.Set(k, v)
71+ }
72+
73+ resp, err := http.DefaultClient.Do(req)
74+ if err != nil {
75+ return fmt.Errorf("fetch source: %w", err)
76+ }
77+ defer resp.Body.Close()
78+
79+ if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent {
80+ return fmt.Errorf("source returned HTTP %d", resp.StatusCode)
81+ }
82+
83+ // --- Step 2: Create read AVIO callback from HTTP body ---
84+ body := resp.Body
85+ readFn := func(_ unsafe.Pointer, buf unsafe.Pointer, bufSize int32) int32 {
86+ if bufSize <= 0 {
87+ return -1
88+ }
89+ goBuf := unsafe.Slice((*byte)(buf), int(bufSize))
90+ n, readErr := body.Read(goBuf)
91+ if n > 0 {
92+ return int32(n)
93+ }
94+ if readErr != nil {
95+ // Return AVERROR_EOF for EOF, generic error otherwise.
96+ return averrorEOF
97+ }
98+ return -1
99+ }
100+ p.readCb = purego.NewCallback(readFn)
101+
102+ readAVIO := ffmpeg.AvioAllocContext(avioBufSize, false, nil, p.readCb, 0, 0)
103+ if readAVIO == nil {
104+ return errors.New("failed to allocate read AVIO context")
105+ }
106+ defer ffmpeg.AvioContextFree(unsafe.Pointer(&readAVIO))
107+
108+ // --- Step 3: Open input format context with custom AVIO ---
109+ inFmtCtx := ffmpeg.FormatAllocContext()
110+ if inFmtCtx == nil {
111+ return errors.New("failed to allocate input format context")
112+ }
113+
114+ ffmpeg.FmtCtxSetPB(inFmtCtx, readAVIO)
115+ ffmpeg.FmtCtxSetFlags(inFmtCtx, ffmpeg.FmtCtxFlags(inFmtCtx)|ffmpeg.AVFMT_FLAG_CUSTOM_IO)
116+
117+ if ret := ffmpeg.FormatOpenInput(unsafe.Pointer(&inFmtCtx), "", nil, nil); ret < 0 {
118+ ffmpeg.FormatFreeContext(inFmtCtx)
119+ return fmt.Errorf("open input: %d", ret)
120+ }
121+ defer ffmpeg.FormatCloseInput(unsafe.Pointer(&inFmtCtx))
122+
123+ // --- Step 4: Find stream info ---
124+ if ret := ffmpeg.FormatFindStreamInfo(inFmtCtx, nil); ret < 0 {
125+ return fmt.Errorf("find stream info: %d", ret)
126+ }
127+
128+ // --- Step 5: Find best video and audio streams ---
129+ videoIdx := ffmpeg.FindBestStream(inFmtCtx, int32(ffmpeg.AVMEDIA_TYPE_VIDEO), -1, -1, nil, 0)
130+ audioIdx := ffmpeg.FindBestStream(inFmtCtx, int32(ffmpeg.AVMEDIA_TYPE_AUDIO), -1, -1, nil, 0)
131+
132+ if videoIdx < 0 {
133+ return errors.New("no video stream found in source")
134+ }
135+
136+ videoStream := ffmpeg.FmtCtxStream(inFmtCtx, int(videoIdx))
137+ videoStreamWrap := (*ffmpeg.Stream)(videoStream)
138+ videoCodecPar := videoStreamWrap.Codecpar()
139+
140+ // --- Step 6: Open video decoder ---
141+ vidDecCtx, err := p.openVideoDecoder(videoCodecPar)
142+ if err != nil {
143+ return fmt.Errorf("open video decoder: %w", err)
144+ }
145+ defer ffmpeg.CodecFreeContext(unsafe.Pointer(&vidDecCtx))
146+
147+ // --- Step 7-8: Open video encoder with HW accel ---
148+ hwDeviceCtx, vidEncCtx, err := p.openVideoEncoder(vidDecCtx)
149+ if err != nil {
150+ return fmt.Errorf("open video encoder: %w", err)
151+ }
152+ if hwDeviceCtx != nil {
153+ defer ffmpeg.BufferUnref(unsafe.Pointer(&hwDeviceCtx))
154+ }
155+ defer ffmpeg.CodecFreeContext(unsafe.Pointer(&vidEncCtx))
156+
157+ // --- Step 9: Create output format context for WebM ---
158+ var outFmtCtx unsafe.Pointer
159+ if ret := ffmpeg.FormatAllocOutputContext2(unsafe.Pointer(&outFmtCtx), nil, "webm", ""); ret < 0 {
160+ return fmt.Errorf("alloc output context: %d", ret)
161+ }
162+ defer ffmpeg.FormatFreeContext(outFmtCtx)
163+
164+ // --- Step 10: Create write AVIO callback ---
165+ pw := p.pw
166+ writeFn := func(_ unsafe.Pointer, buf unsafe.Pointer, bufSize int32) int32 {
167+ if bufSize <= 0 {
168+ return 0
169+ }
170+ goBuf := unsafe.Slice((*byte)(buf), int(bufSize))
171+ n, writeErr := pw.Write(goBuf)
172+ if writeErr != nil {
173+ return -1
174+ }
175+ return int32(n)
176+ }
177+ p.writeCb = purego.NewCallback(writeFn)
178+
179+ writeAVIO := ffmpeg.AvioAllocContext(avioBufSize, true, nil, 0, p.writeCb, 0)
180+ if writeAVIO == nil {
181+ return errors.New("failed to allocate write AVIO context")
182+ }
183+ defer ffmpeg.AvioContextFree(unsafe.Pointer(&writeAVIO))
184+
185+ ffmpeg.FmtCtxSetPB(outFmtCtx, writeAVIO)
186+ ffmpeg.FmtCtxSetFlags(outFmtCtx, ffmpeg.FmtCtxFlags(outFmtCtx)|ffmpeg.AVFMT_FLAG_CUSTOM_IO)
187+
188+ // --- Step 11: Add video output stream ---
189+ outVideoStream := ffmpeg.FormatNewStream(outFmtCtx, nil)
190+ if outVideoStream == nil {
191+ return errors.New("failed to create output video stream")
192+ }
193+ outVideoStreamWrap := (*ffmpeg.Stream)(outVideoStream)
194+ outVideoIdx := int(outVideoStreamWrap.Index())
195+
196+ if ret := ffmpeg.CodecParametersFromContext(outVideoStreamWrap.Codecpar(), vidEncCtx); ret < 0 {
197+ return fmt.Errorf("copy video encoder params to stream: %d", ret)
198+ }
199+
200+ // --- Step 11b: Add audio output stream (if audio present) ---
201+ var audioTx *audioTranscoder
202+ if audioIdx >= 0 {
203+ audioStream := ffmpeg.FmtCtxStream(inFmtCtx, int(audioIdx))
204+ audioStreamWrap := (*ffmpeg.Stream)(audioStream)
205+ audioCodecPar := audioStreamWrap.Codecpar()
206+
207+ var txErr error
208+ audioTx, txErr = newAudioTranscoder(audioCodecPar, outFmtCtx, int(audioIdx))
209+ if txErr != nil {
210+ // Audio transcoding failure is non-fatal; we proceed with video only.
211+ audioTx = nil
212+ audioIdx = -1
213+ }
214+ }
215+ if audioTx != nil {
216+ defer audioTx.close()
217+ }
218+
219+ // --- Step 12: Write header ---
220+ if ret := ffmpeg.FormatWriteHeader(outFmtCtx, nil); ret < 0 {
221+ return fmt.Errorf("write header: %d", ret)
222+ }
223+
224+ // --- Step 13: Main decode/encode loop ---
225+ pkt := ffmpeg.PacketAlloc()
226+ if pkt == nil {
227+ return errors.New("failed to allocate read packet")
228+ }
229+ defer ffmpeg.PacketFree(unsafe.Pointer(&pkt))
230+
231+ frame := ffmpeg.FrameAlloc()
232+ if frame == nil {
233+ return errors.New("failed to allocate decode frame")
234+ }
235+ defer ffmpeg.FrameFree(unsafe.Pointer(&frame))
236+
237+ encPkt := ffmpeg.PacketAlloc()
238+ if encPkt == nil {
239+ return errors.New("failed to allocate encode packet")
240+ }
241+ defer ffmpeg.PacketFree(unsafe.Pointer(&encPkt))
242+
243+ for {
244+ // Check context cancellation.
245+ select {
246+ case <-ctx.Done():
247+ return ctx.Err()
248+ default:
249+ }
250+
251+ ret := ffmpeg.ReadFrame(inFmtCtx, pkt)
252+ if ret < 0 {
253+ // EOF or error — break to flush.
254+ break
255+ }
256+
257+ streamIdx := pktStreamIndex(pkt)
258+
259+ switch {
260+ case streamIdx == videoIdx:
261+ if err := p.transcodeVideoPacket(pkt, frame, encPkt, vidDecCtx, vidEncCtx, outFmtCtx, videoStreamWrap, outVideoIdx); err != nil {
262+ ffmpeg.PacketUnref(pkt)
263+ return fmt.Errorf("video transcode: %w", err)
264+ }
265+
266+ case audioTx != nil && streamIdx == audioIdx:
267+ if err := audioTx.processPacket(pkt, outFmtCtx); err != nil {
268+ ffmpeg.PacketUnref(pkt)
269+ return fmt.Errorf("audio transcode: %w", err)
270+ }
271+ }
272+
273+ ffmpeg.PacketUnref(pkt)
274+ }
275+
276+ // --- Step 14: Flush decoders and encoders ---
277+ // Flush video decoder.
278+ ffmpeg.CodecSendPacket(vidDecCtx, nil)
279+ for {
280+ ret := ffmpeg.CodecReceiveFrame(vidDecCtx, frame)
281+ if ret < 0 {
282+ break
283+ }
284+ p.encodeAndWriteVideoFrame(frame, encPkt, vidEncCtx, outFmtCtx, videoStreamWrap, outVideoIdx)
285+ ffmpeg.FrameUnref(frame)
286+ }
287+
288+ // Flush video encoder.
289+ ffmpeg.CodecSendFrame(vidEncCtx, nil)
290+ for {
291+ ret := ffmpeg.CodecReceivePacket(vidEncCtx, encPkt)
292+ if ret < 0 {
293+ break
294+ }
295+ pktSetStreamIndex(encPkt, int32(outVideoIdx))
296+ outVidStream := ffmpeg.FmtCtxStream(outFmtCtx, outVideoIdx)
297+ outVidStreamWrap := (*ffmpeg.Stream)(outVidStream)
298+ ffmpeg.PacketRescaleTs(encPkt, ffmpeg.CodecCtxTimeBase(vidEncCtx), outVidStreamWrap.TimeBase())
299+ ffmpeg.InterleavedWriteFrame(outFmtCtx, encPkt)
300+ ffmpeg.PacketUnref(encPkt)
301+ }
302+
303+ // Flush audio.
304+ if audioTx != nil {
305+ audioTx.flush(outFmtCtx)
306+ }
307+
308+ // --- Step 15: Write trailer ---
309+ if ret := ffmpeg.WriteTrailer(outFmtCtx); ret < 0 {
310+ return fmt.Errorf("write trailer: %d", ret)
311+ }
312+
313+ return nil
314+}
315+
316+// openVideoDecoder creates and opens a decoder context for the video stream.
317+// It prefers hardware decoders (h264_vaapi, h264_cuvid) based on hwCaps,
318+// falling back to the generic software decoder.
319+func (p *pipeline) openVideoDecoder(codecPar unsafe.Pointer) (unsafe.Pointer, error) {
320+ codecID := ffmpeg.CodecParCodecID(codecPar)
321+
322+ // Try HW decoder first.
323+ var dec unsafe.Pointer
324+ for _, name := range p.hwCaps.Decoders {
325+ dec = ffmpeg.CodecFindDecoderByName(name)
326+ if dec != nil {
327+ break
328+ }
329+ }
330+
331+ // Fallback to software decoder.
332+ if dec == nil {
333+ dec = ffmpeg.CodecFindDecoder(codecID)
334+ }
335+ if dec == nil {
336+ return nil, fmt.Errorf("no decoder found for codec ID %d", codecID)
337+ }
338+
339+ decCtx := ffmpeg.CodecAllocContext3(dec)
340+ if decCtx == nil {
341+ return nil, errors.New("failed to allocate video decoder context")
342+ }
343+
344+ if ret := ffmpeg.CodecParametersToContext(decCtx, codecPar); ret < 0 {
345+ ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx))
346+ return nil, fmt.Errorf("copy video codec params: %d", ret)
347+ }
348+
349+ if ret := ffmpeg.CodecOpen2(decCtx, dec, nil); ret < 0 {
350+ ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx))
351+ return nil, fmt.Errorf("open video decoder: %d", ret)
352+ }
353+
354+ return decCtx, nil
355+}
356+
357+// openVideoEncoder creates a hardware device context and encoder for
358+// the video stream. It uses the first available encoder from hwCaps.Encoders.
359+func (p *pipeline) openVideoEncoder(decCtx unsafe.Pointer) (hwDeviceCtx unsafe.Pointer, encCtx unsafe.Pointer, err error) {
360+ if len(p.hwCaps.Encoders) == 0 {
361+ return nil, nil, errors.New("no GPU encoders available")
362+ }
363+
364+ // Create the HW device context.
365+ var deviceType int32
366+ switch p.hwCaps.API {
367+ case "vaapi":
368+ deviceType = ffmpeg.AV_HWDEVICE_TYPE_VAAPI
369+ case "nvenc":
370+ deviceType = ffmpeg.AV_HWDEVICE_TYPE_CUDA
371+ default:
372+ return nil, nil, fmt.Errorf("unknown HW API: %s", p.hwCaps.API)
373+ }
374+
375+ hwDeviceCtx, hwErr := ffmpeg.HWDeviceCtxCreate(deviceType, p.hwCaps.Device)
376+ if hwErr != nil {
377+ return nil, nil, fmt.Errorf("create HW device: %w", hwErr)
378+ }
379+
380+ // Find the first working encoder.
381+ var enc unsafe.Pointer
382+ var encoderName string
383+ for _, name := range p.hwCaps.Encoders {
384+ enc = ffmpeg.CodecFindEncoderByName(name)
385+ if enc != nil {
386+ encoderName = name
387+ break
388+ }
389+ }
390+ if enc == nil {
391+ ffmpeg.BufferUnref(unsafe.Pointer(&hwDeviceCtx))
392+ return nil, nil, errors.New("no usable encoder codec found")
393+ }
394+
395+ encCtx = ffmpeg.CodecAllocContext3(enc)
396+ if encCtx == nil {
397+ ffmpeg.BufferUnref(unsafe.Pointer(&hwDeviceCtx))
398+ return nil, nil, errors.New("failed to allocate video encoder context")
399+ }
400+
401+ // Copy dimensions and framerate from decoder.
402+ width := ffmpeg.CodecCtxWidth(decCtx)
403+ height := ffmpeg.CodecCtxHeight(decCtx)
404+ ffmpeg.CodecCtxSetWidth(encCtx, width)
405+ ffmpeg.CodecCtxSetHeight(encCtx, height)
406+
407+ framerate := ffmpeg.CodecCtxFramerate(decCtx)
408+ if framerate.Num == 0 {
409+ framerate = ffmpeg.AVRational{Num: 30, Den: 1}
410+ }
411+ ffmpeg.CodecCtxSetFramerate(encCtx, framerate)
412+ ffmpeg.CodecCtxSetTimeBase(encCtx, ffmpeg.AVRational{Num: framerate.Den, Den: framerate.Num})
413+
414+ // Set pixel format based on the API.
415+ if p.hwCaps.API == "vaapi" {
416+ ffmpeg.CodecCtxSetPixFmt(encCtx, int32(ffmpeg.PixelFormatPixFmtVaapi))
417+ } else {
418+ // CUDA/NVENC uses NV12.
419+ ffmpeg.CodecCtxSetPixFmt(encCtx, int32(ffmpeg.PixelFormatPixFmtNv12))
420+ }
421+
422+ // Set GOP size to 2 seconds worth of frames.
423+ gopSize := int32(framerate.Num / framerate.Den * 2)
424+ if gopSize <= 0 {
425+ gopSize = 60
426+ }
427+ codecCtxSetGopSize(encCtx, gopSize)
428+
429+ // Set quality/bitrate based on quality parameter.
430+ p.applyQualityPreset(encCtx, encoderName, width, height)
431+
432+ // Attach HW device context. Use BufferRef to create a new reference
433+ // since the encoder will take ownership of one ref.
434+ hwDeviceRef := ffmpeg.BufferRef(hwDeviceCtx)
435+ if hwDeviceRef == nil {
436+ ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx))
437+ ffmpeg.BufferUnref(unsafe.Pointer(&hwDeviceCtx))
438+ return nil, nil, errors.New("failed to ref HW device context for encoder")
439+ }
440+ ffmpeg.CodecCtxSetHwDeviceCtx(encCtx, hwDeviceRef)
441+
442+ // For VAAPI encoders, we need a HW frames context.
443+ if p.hwCaps.API == "vaapi" {
444+ framesRef := ffmpeg.HWFrameCtxAlloc(hwDeviceCtx)
445+ if framesRef == nil {
446+ ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx))
447+ ffmpeg.BufferUnref(unsafe.Pointer(&hwDeviceCtx))
448+ return nil, nil, errors.New("failed to allocate HW frames context")
449+ }
450+
451+ // Configure the frames context. The HWFramesContext struct starts
452+ // with an AVBufferRef header. The actual AVHWFramesContext data is
453+ // accessible after dereferencing. We configure via av_opt or direct
454+ // offset access. For simplicity, use the known offsets:
455+ // AVHWFramesContext (within the AVBufferRef->data):
456+ // format (sw_format) at offset 8, width at 12, height at 16,
457+ // sw_format at 20, initial_pool_size at 24.
458+ // Actually, the buffer ref data pointer leads to AVHWFramesContext.
459+ // The fields are: AVClass* (8), AVBufferRef* device_ref (8),
460+ // AVBufferPool* pool (8), int initial_pool_size (4),
461+ // enum AVPixelFormat format (4), enum AVPixelFormat sw_format (4),
462+ // int width (4), int height (4).
463+ // So from the data pointer:
464+ // offset 24: initial_pool_size (int32)
465+ // offset 28: format (int32) — the HW pixel format
466+ // offset 32: sw_format (int32) — the SW pixel format
467+ // offset 36: width (int32)
468+ // offset 40: height (int32)
469+ //
470+ // We access data via the AVBufferRef at offset 0 (data field at offset 0).
471+ hwFramesData := *(*unsafe.Pointer)(framesRef) // AVBufferRef->data
472+ if hwFramesData != nil {
473+ *(*int32)(unsafe.Add(hwFramesData, 24)) = 20 // initial_pool_size
474+ *(*int32)(unsafe.Add(hwFramesData, 28)) = int32(ffmpeg.PixelFormatPixFmtVaapi)
475+ *(*int32)(unsafe.Add(hwFramesData, 32)) = int32(ffmpeg.PixelFormatPixFmtNv12) // sw_format
476+ *(*int32)(unsafe.Add(hwFramesData, 36)) = width
477+ *(*int32)(unsafe.Add(hwFramesData, 40)) = height
478+ }
479+
480+ if ret := ffmpeg.HWFrameCtxInit(framesRef); ret < 0 {
481+ ffmpeg.BufferUnref(unsafe.Pointer(&framesRef))
482+ ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx))
483+ ffmpeg.BufferUnref(unsafe.Pointer(&hwDeviceCtx))
484+ return nil, nil, fmt.Errorf("init HW frames context: %d", ret)
485+ }
486+
487+ ffmpeg.CodecCtxSetHwFramesCtx(encCtx, framesRef)
488+ }
489+
490+ if ret := ffmpeg.CodecOpen2(encCtx, enc, nil); ret < 0 {
491+ ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx))
492+ ffmpeg.BufferUnref(unsafe.Pointer(&hwDeviceCtx))
493+ return nil, nil, fmt.Errorf("open video encoder %s: %d", encoderName, ret)
494+ }
495+
496+ return hwDeviceCtx, encCtx, nil
497+}
498+
499+// applyQualityPreset sets bitrate and encoder options based on the
500+// quality string ("low", "medium", "high").
501+func (p *pipeline) applyQualityPreset(encCtx unsafe.Pointer, encoderName string, width, height int32) {
502+ pixels := int64(width) * int64(height)
503+
504+ // Base bitrate scaled to resolution (targeting 1080p baseline).
505+ // low: ~1.5 Mbps at 1080p, medium: ~3 Mbps, high: ~6 Mbps.
506+ var bitsPerPixel float64
507+ switch p.quality {
508+ case "low":
509+ bitsPerPixel = 0.07
510+ case "high":
511+ bitsPerPixel = 0.30
512+ default: // "medium"
513+ bitsPerPixel = 0.15
514+ }
515+
516+ bitrate := int64(float64(pixels) * bitsPerPixel)
517+ if bitrate < 500_000 {
518+ bitrate = 500_000 // floor at 500 kbps
519+ }
520+ codecCtxSetBitRate(encCtx, bitrate)
521+
522+ // Set encoder-specific options via av_opt_set.
523+ if strings.HasSuffix(encoderName, "_vaapi") {
524+ // VAAPI encoders use rc_mode for rate control.
525+ ffmpeg.AVOptSet(encCtx, "rc_mode", "VBR", 0)
526+ } else if strings.HasSuffix(encoderName, "_nvenc") {
527+ // NVENC uses preset and rc.
528+ switch p.quality {
529+ case "low":
530+ ffmpeg.AVOptSet(encCtx, "preset", "p4", 0)
531+ case "high":
532+ ffmpeg.AVOptSet(encCtx, "preset", "p7", 0)
533+ default:
534+ ffmpeg.AVOptSet(encCtx, "preset", "p5", 0)
535+ }
536+ ffmpeg.AVOptSet(encCtx, "rc", "vbr", 0)
537+ }
538+}
539+
540+// transcodeVideoPacket decodes a video packet, encodes the resulting
541+// frames, and writes them to the output.
542+func (p *pipeline) transcodeVideoPacket(pkt, frame, encPkt, decCtx, encCtx, outFmtCtx unsafe.Pointer, inStreamWrap *ffmpeg.Stream, outVideoIdx int) error {
543+ ret := ffmpeg.CodecSendPacket(decCtx, pkt)
544+ if ret < 0 {
545+ return fmt.Errorf("video decode send packet: %d", ret)
546+ }
547+
548+ for {
549+ ret = ffmpeg.CodecReceiveFrame(decCtx, frame)
550+ if ret < 0 {
551+ // EAGAIN or EOF.
552+ break
553+ }
554+
555+ if err := p.encodeAndWriteVideoFrame(frame, encPkt, encCtx, outFmtCtx, inStreamWrap, outVideoIdx); err != nil {
556+ ffmpeg.FrameUnref(frame)
557+ return err
558+ }
559+ ffmpeg.FrameUnref(frame)
560+ }
561+
562+ return nil
563+}
564+
565+// encodeAndWriteVideoFrame sends a decoded frame to the encoder and
566+// writes any resulting packets to the output.
567+func (p *pipeline) encodeAndWriteVideoFrame(frame, encPkt, encCtx, outFmtCtx unsafe.Pointer, inStreamWrap *ffmpeg.Stream, outVideoIdx int) error {
568+ // If the frame is in SW format and the encoder expects HW format,
569+ // we would need to upload. For now, send directly — the HW encoder
570+ // with hw_device_ctx set should handle format conversion internally
571+ // when the decoder outputs SW frames.
572+
573+ ret := ffmpeg.CodecSendFrame(encCtx, frame)
574+ if ret < 0 {
575+ return fmt.Errorf("video encode send frame: %d", ret)
576+ }
577+
578+ for {
579+ ret = ffmpeg.CodecReceivePacket(encCtx, encPkt)
580+ if ret < 0 {
581+ break
582+ }
583+
584+ pktSetStreamIndex(encPkt, int32(outVideoIdx))
585+
586+ outVidStream := ffmpeg.FmtCtxStream(outFmtCtx, outVideoIdx)
587+ outVidStreamWrap := (*ffmpeg.Stream)(outVidStream)
588+ ffmpeg.PacketRescaleTs(encPkt, ffmpeg.CodecCtxTimeBase(encCtx), outVidStreamWrap.TimeBase())
589+
590+ if wret := ffmpeg.InterleavedWriteFrame(outFmtCtx, encPkt); wret < 0 {
591+ ffmpeg.PacketUnref(encPkt)
592+ return fmt.Errorf("video write frame: %d", wret)
593+ }
594+ ffmpeg.PacketUnref(encPkt)
595+ }
596+
597+ return nil
598+}
599+
600+// averrorEOF is the AVERROR_EOF constant.
601+const averrorEOF = -('E' | 'O'<<8 | 'F'<<16 | ' '<<24)
A internal/infrastructure/transcoder/pool.go +100 -0
@@ -0,0 +1,100 @@
1+package transcoder
2+
3+import (
4+ "context"
5+ "errors"
6+ "io"
7+ "sync"
8+ "sync/atomic"
9+ "time"
10+
11+ "github.com/bnema/dumber/internal/application/port"
12+)
13+
14+// Compile-time check: session implements port.TranscodeSession.
15+var _ port.TranscodeSession = (*session)(nil)
16+
17+// session wraps an io.PipeReader fed by a pipeline goroutine.
18+type session struct {
19+ id string
20+ pr *io.PipeReader
21+ cancel context.CancelFunc
22+ createdAt time.Time
23+ lastRead atomic.Int64 // unix timestamp of last Read() call
24+}
25+
26+// Read delegates to the underlying pipe reader and records the timestamp.
27+func (s *session) Read(buf []byte) (int, error) {
28+ n, err := s.pr.Read(buf)
29+ if n > 0 {
30+ s.lastRead.Store(time.Now().Unix())
31+ }
32+ return n, err
33+}
34+
35+// Close cancels the pipeline context and closes the pipe reader.
36+func (s *session) Close() error {
37+ s.cancel()
38+ return s.pr.Close()
39+}
40+
41+// ContentType returns the MIME type of the transcoded output.
42+func (s *session) ContentType() string {
43+ return "video/webm"
44+}
45+
46+// errPoolAtCapacity is returned when the session pool has reached its
47+// maximum number of concurrent sessions.
48+var errPoolAtCapacity = errors.New("transcoder: session pool at capacity")
49+
50+// sessionPool tracks active transcode sessions with a concurrency limit.
51+type sessionPool struct {
52+ mu sync.Mutex
53+ sessions map[string]*session
54+ maxConc int
55+}
56+
57+// newSessionPool creates a pool with the given concurrency limit.
58+func newSessionPool(maxConcurrent int) *sessionPool {
59+ return &sessionPool{
60+ sessions: make(map[string]*session),
61+ maxConc: maxConcurrent,
62+ }
63+}
64+
65+// add registers a session in the pool. Returns errPoolAtCapacity if the
66+// pool is already at its maximum concurrency.
67+func (p *sessionPool) add(s *session) error {
68+ p.mu.Lock()
69+ defer p.mu.Unlock()
70+ if len(p.sessions) >= p.maxConc {
71+ return errPoolAtCapacity
72+ }
73+ p.sessions[s.id] = s
74+ return nil
75+}
76+
77+// remove unregisters a session from the pool.
78+func (p *sessionPool) remove(id string) {
79+ p.mu.Lock()
80+ defer p.mu.Unlock()
81+ delete(p.sessions, id)
82+}
83+
84+// count returns the number of active sessions.
85+func (p *sessionPool) count() int {
86+ p.mu.Lock()
87+ defer p.mu.Unlock()
88+ return len(p.sessions)
89+}
90+
91+// closeAll cancels and closes every active session.
92+func (p *sessionPool) closeAll() {
93+ p.mu.Lock()
94+ defer p.mu.Unlock()
95+ for id, s := range p.sessions {
96+ s.cancel()
97+ s.pr.Close()
98+ delete(p.sessions, id)
99+ }
100+}
@@ -0,0 +1,100 @@
1+package transcoder
2+
3+import (
4+ "context"
5+ "errors"
6+ "io"
7+ "sync"
8+ "sync/atomic"
9+ "time"
10+
11+ "github.com/bnema/dumber/internal/application/port"
12+)
13+
14+// Compile-time check: session implements port.TranscodeSession.
15+var _ port.TranscodeSession = (*session)(nil)
16+
17+// session wraps an io.PipeReader fed by a pipeline goroutine.
18+type session struct {
19+ id string
20+ pr *io.PipeReader
21+ cancel context.CancelFunc
22+ createdAt time.Time
23+ lastRead atomic.Int64 // unix timestamp of last Read() call
24+}
25+
26+// Read delegates to the underlying pipe reader and records the timestamp.
27+func (s *session) Read(buf []byte) (int, error) {
28+ n, err := s.pr.Read(buf)
29+ if n > 0 {
30+ s.lastRead.Store(time.Now().Unix())
31+ }
32+ return n, err
33+}
34+
35+// Close cancels the pipeline context and closes the pipe reader.
36+func (s *session) Close() error {
37+ s.cancel()
38+ return s.pr.Close()
39+}
40+
41+// ContentType returns the MIME type of the transcoded output.
42+func (s *session) ContentType() string {
43+ return "video/webm"
44+}
45+
46+// errPoolAtCapacity is returned when the session pool has reached its
47+// maximum number of concurrent sessions.
48+var errPoolAtCapacity = errors.New("transcoder: session pool at capacity")
49+
50+// sessionPool tracks active transcode sessions with a concurrency limit.
51+type sessionPool struct {
52+ mu sync.Mutex
53+ sessions map[string]*session
54+ maxConc int
55+}
56+
57+// newSessionPool creates a pool with the given concurrency limit.
58+func newSessionPool(maxConcurrent int) *sessionPool {
59+ return &sessionPool{
60+ sessions: make(map[string]*session),
61+ maxConc: maxConcurrent,
62+ }
63+}
64+
65+// add registers a session in the pool. Returns errPoolAtCapacity if the
66+// pool is already at its maximum concurrency.
67+func (p *sessionPool) add(s *session) error {
68+ p.mu.Lock()
69+ defer p.mu.Unlock()
70+ if len(p.sessions) >= p.maxConc {
71+ return errPoolAtCapacity
72+ }
73+ p.sessions[s.id] = s
74+ return nil
75+}
76+
77+// remove unregisters a session from the pool.
78+func (p *sessionPool) remove(id string) {
79+ p.mu.Lock()
80+ defer p.mu.Unlock()
81+ delete(p.sessions, id)
82+}
83+
84+// count returns the number of active sessions.
85+func (p *sessionPool) count() int {
86+ p.mu.Lock()
87+ defer p.mu.Unlock()
88+ return len(p.sessions)
89+}
90+
91+// closeAll cancels and closes every active session.
92+func (p *sessionPool) closeAll() {
93+ p.mu.Lock()
94+ defer p.mu.Unlock()
95+ for id, s := range p.sessions {
96+ s.cancel()
97+ s.pr.Close()
98+ delete(p.sessions, id)
99+ }
100+}
A internal/infrastructure/transcoder/transcoder.go +139 -0
@@ -0,0 +1,139 @@
1+package transcoder
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "io"
8+ "time"
9+
10+ "github.com/google/uuid"
11+ "github.com/rs/zerolog"
12+
13+ "github.com/bnema/dumber/internal/application/port"
14+ "github.com/bnema/dumber/internal/infrastructure/config"
15+)
16+
17+// Compile-time check: Transcoder implements port.MediaTranscoder.
18+var _ port.MediaTranscoder = (*Transcoder)(nil)
19+
20+// Transcoder implements port.MediaTranscoder using GPU-accelerated FFmpeg
21+// pipelines. It probes hardware capabilities at creation time and manages
22+// a bounded pool of concurrent transcode sessions.
23+type Transcoder struct {
24+ cfg config.TranscodingConfig
25+ hwCaps port.HWCapabilities
26+ pool *sessionPool
27+ logger zerolog.Logger
28+}
29+
30+// New creates a Transcoder. It probes the GPU at creation time using the
31+// configured hardware acceleration preference. If no compatible GPU
32+// encoder is found, Available() will return false and Start() will fail.
33+func New(cfg config.TranscodingConfig, logger *zerolog.Logger) *Transcoder {
34+ maxConc := cfg.MaxConcurrent
35+ if maxConc <= 0 {
36+ maxConc = 2
37+ }
38+
39+ quality := cfg.Quality
40+ if quality == "" {
41+ quality = "medium"
42+ }
43+ cfg.Quality = quality
44+
45+ hwaccel := cfg.HWAccel
46+ if hwaccel == "" {
47+ hwaccel = "auto"
48+ }
49+
50+ l := logger.With().Str("component", "transcoder").Logger()
51+
52+ hwCaps := ProbeGPU(hwaccel, &l)
53+ if len(hwCaps.Encoders) > 0 {
54+ l.Info().
55+ Str("api", hwCaps.API).
56+ Strs("encoders", hwCaps.Encoders).
57+ Strs("decoders", hwCaps.Decoders).
58+ Int("max_concurrent", maxConc).
59+ Msg("GPU transcoding available")
60+ } else {
61+ l.Warn().Msg("no GPU encoder found, transcoding will be unavailable")
62+ }
63+
64+ return &Transcoder{
65+ cfg: cfg,
66+ hwCaps: hwCaps,
67+ pool: newSessionPool(maxConc),
68+ logger: l,
69+ }
70+}
71+
72+// Available returns true if a compatible GPU encoder was found during
73+// hardware probing.
74+func (t *Transcoder) Available() bool {
75+ return len(t.hwCaps.Encoders) > 0
76+}
77+
78+// Capabilities returns the detected GPU hardware info.
79+func (t *Transcoder) Capabilities() port.HWCapabilities {
80+ return t.hwCaps
81+}
82+
83+// Start begins a transcode session. It spawns a goroutine running the
84+// FFmpeg pipeline and returns a TranscodeSession whose Read() streams
85+// the transcoded WebM output. The session is automatically removed from
86+// the pool when the pipeline completes or the context is cancelled.
87+func (t *Transcoder) Start(ctx context.Context, sourceURL string, headers map[string]string) (port.TranscodeSession, error) {
88+ if !t.Available() {
89+ return nil, errors.New("transcoder: no GPU encoder available")
90+ }
91+
92+ // Create a cancellable context for this session.
93+ sessionCtx, cancel := context.WithCancel(ctx)
94+
95+ pr, pw := io.Pipe()
96+
97+ id := uuid.NewString()
98+ s := &session{
99+ id: id,
100+ pr: pr,
101+ cancel: cancel,
102+ createdAt: time.Now(),
103+ }
104+ s.lastRead.Store(time.Now().Unix())
105+
106+ // Check capacity and register before spawning the goroutine.
107+ if err := t.pool.add(s); err != nil {
108+ cancel()
109+ pr.Close()
110+ pw.Close()
111+ return nil, fmt.Errorf("transcoder: %w", err)
112+ }
113+
114+ p := newPipeline(t.hwCaps, sourceURL, headers, t.cfg.Quality, pw)
115+
116+ t.logger.Info().
117+ Str("session_id", id).
118+ Str("source", sourceURL).
119+ Int("active_sessions", t.pool.count()).
120+ Msg("starting transcode session")
121+
122+ go func() {
123+ p.run(sessionCtx)
124+ t.pool.remove(id)
125+ t.logger.Info().
126+ Str("session_id", id).
127+ Msg("transcode session finished")
128+ }()
129+
130+ return s, nil
131+}
132+
133+// Close shuts down all active transcode sessions.
134+func (t *Transcoder) Close() {
135+ t.logger.Info().
136+ Int("active_sessions", t.pool.count()).
137+ Msg("closing all transcode sessions")
138+ t.pool.closeAll()
139+}
@@ -0,0 +1,139 @@
1+package transcoder
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "io"
8+ "time"
9+
10+ "github.com/google/uuid"
11+ "github.com/rs/zerolog"
12+
13+ "github.com/bnema/dumber/internal/application/port"
14+ "github.com/bnema/dumber/internal/infrastructure/config"
15+)
16+
17+// Compile-time check: Transcoder implements port.MediaTranscoder.
18+var _ port.MediaTranscoder = (*Transcoder)(nil)
19+
20+// Transcoder implements port.MediaTranscoder using GPU-accelerated FFmpeg
21+// pipelines. It probes hardware capabilities at creation time and manages
22+// a bounded pool of concurrent transcode sessions.
23+type Transcoder struct {
24+ cfg config.TranscodingConfig
25+ hwCaps port.HWCapabilities
26+ pool *sessionPool
27+ logger zerolog.Logger
28+}
29+
30+// New creates a Transcoder. It probes the GPU at creation time using the
31+// configured hardware acceleration preference. If no compatible GPU
32+// encoder is found, Available() will return false and Start() will fail.
33+func New(cfg config.TranscodingConfig, logger *zerolog.Logger) *Transcoder {
34+ maxConc := cfg.MaxConcurrent
35+ if maxConc <= 0 {
36+ maxConc = 2
37+ }
38+
39+ quality := cfg.Quality
40+ if quality == "" {
41+ quality = "medium"
42+ }
43+ cfg.Quality = quality
44+
45+ hwaccel := cfg.HWAccel
46+ if hwaccel == "" {
47+ hwaccel = "auto"
48+ }
49+
50+ l := logger.With().Str("component", "transcoder").Logger()
51+
52+ hwCaps := ProbeGPU(hwaccel, &l)
53+ if len(hwCaps.Encoders) > 0 {
54+ l.Info().
55+ Str("api", hwCaps.API).
56+ Strs("encoders", hwCaps.Encoders).
57+ Strs("decoders", hwCaps.Decoders).
58+ Int("max_concurrent", maxConc).
59+ Msg("GPU transcoding available")
60+ } else {
61+ l.Warn().Msg("no GPU encoder found, transcoding will be unavailable")
62+ }
63+
64+ return &Transcoder{
65+ cfg: cfg,
66+ hwCaps: hwCaps,
67+ pool: newSessionPool(maxConc),
68+ logger: l,
69+ }
70+}
71+
72+// Available returns true if a compatible GPU encoder was found during
73+// hardware probing.
74+func (t *Transcoder) Available() bool {
75+ return len(t.hwCaps.Encoders) > 0
76+}
77+
78+// Capabilities returns the detected GPU hardware info.
79+func (t *Transcoder) Capabilities() port.HWCapabilities {
80+ return t.hwCaps
81+}
82+
83+// Start begins a transcode session. It spawns a goroutine running the
84+// FFmpeg pipeline and returns a TranscodeSession whose Read() streams
85+// the transcoded WebM output. The session is automatically removed from
86+// the pool when the pipeline completes or the context is cancelled.
87+func (t *Transcoder) Start(ctx context.Context, sourceURL string, headers map[string]string) (port.TranscodeSession, error) {
88+ if !t.Available() {
89+ return nil, errors.New("transcoder: no GPU encoder available")
90+ }
91+
92+ // Create a cancellable context for this session.
93+ sessionCtx, cancel := context.WithCancel(ctx)
94+
95+ pr, pw := io.Pipe()
96+
97+ id := uuid.NewString()
98+ s := &session{
99+ id: id,
100+ pr: pr,
101+ cancel: cancel,
102+ createdAt: time.Now(),
103+ }
104+ s.lastRead.Store(time.Now().Unix())
105+
106+ // Check capacity and register before spawning the goroutine.
107+ if err := t.pool.add(s); err != nil {
108+ cancel()
109+ pr.Close()
110+ pw.Close()
111+ return nil, fmt.Errorf("transcoder: %w", err)
112+ }
113+
114+ p := newPipeline(t.hwCaps, sourceURL, headers, t.cfg.Quality, pw)
115+
116+ t.logger.Info().
117+ Str("session_id", id).
118+ Str("source", sourceURL).
119+ Int("active_sessions", t.pool.count()).
120+ Msg("starting transcode session")
121+
122+ go func() {
123+ p.run(sessionCtx)
124+ t.pool.remove(id)
125+ t.logger.Info().
126+ Str("session_id", id).
127+ Msg("transcode session finished")
128+ }()
129+
130+ return s, nil
131+}
132+
133+// Close shuts down all active transcode sessions.
134+func (t *Transcoder) Close() {
135+ t.logger.Info().
136+ Int("active_sessions", t.pool.count()).
137+ Msg("closing all transcode sessions")
138+ t.pool.closeAll()
139+}

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