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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
16 files · +1995 -1
M go.mod +4 -1
| @@ -4,6 +4,7 @@ go 1.26.1 | |||
| 4 | 4 | ||
| 5 | 5 | require ( | |
| 6 | 6 | github.com/bnema/purego-cef v0.0.0-00010101000000-000000000000 | |
| 7 | + | github.com/bnema/purego-ffmpeg v0.1.0 | |
| 7 | 8 | github.com/bnema/purego-sqlite v0.1.1 | |
| 8 | 9 | github.com/bnema/puregotk v0.2.2 | |
| 9 | 10 | github.com/bnema/puregotk-webkit v0.0.16 | |
| @@ -13,6 +14,7 @@ require ( | |||
| 13 | 14 | github.com/ebitengine/purego v0.10.0 | |
| 14 | 15 | github.com/fsnotify/fsnotify v1.9.0 | |
| 15 | 16 | github.com/godbus/dbus/v5 v5.2.2 | |
| 17 | + | github.com/google/uuid v1.6.0 | |
| 16 | 18 | github.com/pelletier/go-toml/v2 v2.3.0 | |
| 17 | 19 | github.com/pressly/goose/v3 v3.27.0 | |
| 18 | 20 | 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 | 32 | replace github.com/bnema/purego-cef => ../purego-cef | |
| 31 | 33 | ||
| 34 | + | replace github.com/bnema/purego-ffmpeg => ../purego-ffmpeg | |
| 35 | + | ||
| 32 | 36 | require ( | |
| 33 | 37 | github.com/atotto/clipboard v0.1.4 // indirect | |
| 34 | 38 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect | |
| @@ -44,7 +48,6 @@ require ( | |||
| 44 | 48 | github.com/dustin/go-humanize v1.0.1 // indirect | |
| 45 | 49 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect | |
| 46 | 50 | github.com/go-viper/mapstructure/v2 v2.5.0 // indirect | |
| 47 | - | github.com/google/uuid v1.6.0 // indirect | |
| 48 | 51 | github.com/inconshreveable/mousetrap v1.1.0 // indirect | |
| 49 | 52 | github.com/lucasb-eyer/go-colorful v1.3.0 // indirect | |
| 50 | 53 | 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-000000000000 | 6 | 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.1 | 8 | github.com/bnema/purego-sqlite v0.1.1 | ||
| 8 | github.com/bnema/puregotk v0.2.2 | 9 | github.com/bnema/puregotk v0.2.2 | ||
| 9 | github.com/bnema/puregotk-webkit v0.0.16 | 10 | github.com/bnema/puregotk-webkit v0.0.16 | ||
| @@ -13,6 +14,7 @@ require ( | |||||
| 13 | github.com/ebitengine/purego v0.10.0 | 14 | github.com/ebitengine/purego v0.10.0 | ||
| 14 | github.com/fsnotify/fsnotify v1.9.0 | 15 | github.com/fsnotify/fsnotify v1.9.0 | ||
| 15 | github.com/godbus/dbus/v5 v5.2.2 | 16 | 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.0 | 18 | github.com/pelletier/go-toml/v2 v2.3.0 | ||
| 17 | github.com/pressly/goose/v3 v3.27.0 | 19 | github.com/pressly/goose/v3 v3.27.0 | ||
| 18 | github.com/rs/zerolog v1.34.0 | 20 | 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-cef | 32 | 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 // indirect | 37 | github.com/atotto/clipboard v0.1.4 // indirect | ||
| 34 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect | 38 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect | ||
| @@ -44,7 +48,6 @@ require ( | |||||
| 44 | github.com/dustin/go-humanize v1.0.1 // indirect | 48 | github.com/dustin/go-humanize v1.0.1 // indirect | ||
| 45 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect | 49 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect | ||
| 46 | github.com/go-viper/mapstructure/v2 v2.5.0 // indirect | 50 | 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 // indirect | 51 | github.com/inconshreveable/mousetrap v1.1.0 // indirect | ||
| 49 | github.com/lucasb-eyer/go-colorful v1.3.0 // indirect | 52 | github.com/lucasb-eyer/go-colorful v1.3.0 // indirect | ||
| 50 | github.com/mattn/go-colorable v0.1.14 // indirect | 53 | 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 ( | |||
| 14 | 14 | "github.com/rs/zerolog" | |
| 15 | 15 | ||
| 16 | 16 | "github.com/bnema/dumber/assets" | |
| 17 | + | "github.com/bnema/dumber/internal/application/port" | |
| 17 | 18 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 19 | + | transcoderpkg "github.com/bnema/dumber/internal/infrastructure/transcoder" | |
| 18 | 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 | 158 | factory := newWebViewFactory(eng, gl, webViewFactoryOptions{ | |
| 142 | 159 | scale: scale, | |
| 143 | 160 | windowlessFrameRate: windowlessFrameRate, | |
| 144 | 161 | enableContextMenuHandler: cfg.EnableContextMenuHandler, | |
| 162 | + | transcoder: mediaTranscoder, | |
| 145 | 163 | }) | |
| 146 | 164 | pool := newWebViewPool(factory) | |
| 147 | 165 | ||
| @@ -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 { | |||
| 24 | 24 | windowlessFrameRate int32 | |
| 25 | 25 | enableContextMenuHandler bool | |
| 26 | 26 | bgColor atomic.Uint32 // packed ARGB for BrowserSettings.BackgroundColor | |
| 27 | + | transcoder port.MediaTranscoder | |
| 27 | 28 | } | |
| 28 | 29 | ||
| 29 | 30 | type webViewFactoryOptions struct { | |
| 30 | 31 | scale int32 | |
| 31 | 32 | windowlessFrameRate int32 | |
| 32 | 33 | enableContextMenuHandler bool | |
| 34 | + | transcoder port.MediaTranscoder | |
| 33 | 35 | } | |
| 34 | 36 | ||
| 35 | 37 | // newWebViewFactory returns a factory that will create WebViews using the | |
| @@ -47,6 +49,7 @@ func newWebViewFactory(engine *Engine, gl *glLoader, opts webViewFactoryOptions) | |||
| 47 | 49 | scale: opts.scale, | |
| 48 | 50 | windowlessFrameRate: opts.windowlessFrameRate, | |
| 49 | 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 | 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 | 83 | handlers := &handlerSet{ | |
| 76 | 84 | wv: wv, | |
| 77 | 85 | enableContextMenuHandler: f.enableContextMenuHandler, | |
| 86 | + | transcodingHandler: transcodingHandler, | |
| 78 | 87 | } | |
| 79 | 88 | wv.handlers = handlers | |
| 80 | 89 | wv.findCtrl = newFindController() | |
| @@ -24,12 +24,14 @@ type WebViewFactory struct { | |||||
| 24 | windowlessFrameRate int32 | 24 | windowlessFrameRate int32 | ||
| 25 | enableContextMenuHandler bool | 25 | enableContextMenuHandler bool | ||
| 26 | bgColor atomic.Uint32 // packed ARGB for BrowserSettings.BackgroundColor | 26 | 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 int32 | 31 | scale int32 | ||
| 31 | windowlessFrameRate int32 | 32 | windowlessFrameRate int32 | ||
| 32 | enableContextMenuHandler bool | 33 | enableContextMenuHandler bool | ||
| 34 | + | transcoder port.MediaTranscoder | |||
| 33 | } | 35 | } | ||
| 34 | 36 | ||||
| 35 | // newWebViewFactory returns a factory that will create WebViews using the | 37 | // 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 = handlers | 88 | 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 ( | |||
| 13 | 13 | type handlerSet struct { | |
| 14 | 14 | wv *WebView | |
| 15 | 15 | enableContextMenuHandler bool | |
| 16 | + | transcodingHandler purecef.ResourceRequestHandler | |
| 16 | 17 | } | |
| 17 | 18 | ||
| 18 | 19 | // Compile-time interface checks. | |
| @@ -559,6 +560,9 @@ func (h *handlerSet) GetResourceRequestHandler( | |||
| 559 | 560 | _ purecef.Browser, _ purecef.Frame, _ purecef.Request, | |
| 560 | 561 | _, _ int32, _ string, _ *int32, | |
| 561 | 562 | ) purecef.ResourceRequestHandler { | |
| 563 | + | if h.transcodingHandler != nil { | |
| 564 | + | return h.transcodingHandler | |
| 565 | + | } | |
| 562 | 566 | return nil | |
| 563 | 567 | } | |
| 564 | 568 | ||
| @@ -13,6 +13,7 @@ import ( | |||||
| 13 | type handlerSet struct { | 13 | type handlerSet struct { | ||
| 14 | wv *WebView | 14 | wv *WebView | ||
| 15 | enableContextMenuHandler bool | 15 | 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 nil | 566 | return nil | ||
| 563 | } | 567 | } | ||
| 564 | 568 | ||||
M internal/infrastructure/config/schema.go +19 -0
| @@ -98,6 +98,8 @@ type Config struct { | |||
| 98 | 98 | Downloads DownloadsConfig `mapstructure:"downloads" yaml:"downloads" toml:"downloads"` | |
| 99 | 99 | // Engine holds engine selection and unified engine options. | |
| 100 | 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 | 105 | // CookiePolicy controls cookie acceptance behavior. | |
| @@ -362,3 +364,20 @@ type DownloadsConfig struct { | |||
| 362 | 364 | // Empty string means use XDG_DOWNLOAD_DIR or ~/Downloads. | |
| 363 | 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 | + | } | |
| @@ -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 | + | } | |||