p3-delete: remove obsolete transcoding implementation and dependencies
Delete: - internal/infrastructure/cef/transcoding_handler.go - internal/infrastructure/cef/transcoding_handler_test.go - internal/infrastructure/transcoder/ (entire package) - internal/application/port/transcoder.go - internal/infrastructure/netguard/transcode.go - internal/infrastructure/netguard/transcode_test.go - internal/infrastructure/netguard/ (now-empty directory) Cleanup: - Remove MediaClassifier type from cef/runtime_config.go - Remove TranscodingConfig from config/schema.go - Remove port.MediaTranscoder wiring from engine_init, factory, scheme_handler - Fix scheme_handler_test.go calls to match newDumbSchemeHandler signature - Replace maxTranscodingURLLength ref in webview_handlers.go - Remove [transcoding] sections from default config templates - go mod tidy: remove unused github.com/bnema/purego-ffmpeg dependency - go mod vendor: sync vendor directory All tests pass except pre-existing unrelated CEF WASM asset test.
- commit
- e6b7103363457b0164b763dcf79bbddd4aa71ab5
- author
- brice · · 2026-04-28 04:10 UTC
- parent
- a2af3ad
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
25 files · +23 -3303
M go.mod +0 -2
| @@ -7,7 +7,6 @@ require ( | |||
| 7 | 7 | github.com/andybalholm/brotli v1.2.1 | |
| 8 | 8 | github.com/bnema/purego v0.11.0-bnema.2 | |
| 9 | 9 | github.com/bnema/purego-cef v0.11.0 | |
| 10 | - | github.com/bnema/purego-ffmpeg v0.3.1 | |
| 11 | 10 | github.com/bnema/purego-pipewire v0.1.2 | |
| 12 | 11 | github.com/bnema/purego-sqlite v0.1.2 | |
| 13 | 12 | github.com/bnema/puregotk v0.5.1 | |
| @@ -16,7 +15,6 @@ require ( | |||
| 16 | 15 | github.com/charmbracelet/lipgloss v1.1.0 | |
| 17 | 16 | github.com/fsnotify/fsnotify v1.9.0 | |
| 18 | 17 | github.com/godbus/dbus/v5 v5.2.2 | |
| 19 | - | github.com/google/uuid v1.6.0 | |
| 20 | 18 | github.com/pelletier/go-toml/v2 v2.3.0 | |
| 21 | 19 | github.com/pressly/goose/v3 v3.27.1 | |
| 22 | 20 | github.com/rs/zerolog v1.35.1 | |
| @@ -7,7 +7,6 @@ require ( | |||||
| 7 | github.com/andybalholm/brotli v1.2.1 | 7 | github.com/andybalholm/brotli v1.2.1 | ||
| 8 | github.com/bnema/purego v0.11.0-bnema.2 | 8 | github.com/bnema/purego v0.11.0-bnema.2 | ||
| 9 | github.com/bnema/purego-cef v0.11.0 | 9 | github.com/bnema/purego-cef v0.11.0 | ||
| 10 | - | github.com/bnema/purego-ffmpeg v0.3.1 | |||
| 11 | github.com/bnema/purego-pipewire v0.1.2 | 10 | github.com/bnema/purego-pipewire v0.1.2 | ||
| 12 | github.com/bnema/purego-sqlite v0.1.2 | 11 | github.com/bnema/purego-sqlite v0.1.2 | ||
| 13 | github.com/bnema/puregotk v0.5.1 | 12 | github.com/bnema/puregotk v0.5.1 | ||
| @@ -16,7 +15,6 @@ require ( | |||||
| 16 | github.com/charmbracelet/lipgloss v1.1.0 | 15 | github.com/charmbracelet/lipgloss v1.1.0 | ||
| 17 | github.com/fsnotify/fsnotify v1.9.0 | 16 | github.com/fsnotify/fsnotify v1.9.0 | ||
| 18 | github.com/godbus/dbus/v5 v5.2.2 | 17 | github.com/godbus/dbus/v5 v5.2.2 | ||
| 19 | - | github.com/google/uuid v1.6.0 | |||
| 20 | github.com/pelletier/go-toml/v2 v2.3.0 | 18 | github.com/pelletier/go-toml/v2 v2.3.0 | ||
| 21 | github.com/pressly/goose/v3 v3.27.1 | 19 | github.com/pressly/goose/v3 v3.27.1 | ||
| 22 | github.com/rs/zerolog v1.35.1 | 20 | github.com/rs/zerolog v1.35.1 | ||
M go.sum +0 -2
| @@ -14,8 +14,6 @@ github.com/bnema/purego v0.11.0-bnema.2 h1:7pzcdOJ8bGtLrCz6V582oyleP6css1ICQoGgj | |||
| 14 | 14 | github.com/bnema/purego v0.11.0-bnema.2/go.mod h1:5BnOIUj+0fvk/vwv45iottFoGfkDT7cnLXnpM2s6Hng= | |
| 15 | 15 | github.com/bnema/purego-cef v0.11.0 h1:tvHx+DfT3Ubcf/e1vIc4M/rJ5Ad9lDa+y04+63+gchM= | |
| 16 | 16 | github.com/bnema/purego-cef v0.11.0/go.mod h1:AIW4lrXr4f2vesO4WnMDN49yAyfivYHV4x6hxpu5SmM= | |
| 17 | - | github.com/bnema/purego-ffmpeg v0.3.1 h1:BWqRQvRXH3Lu7atem71Ai//fOYdNwuFofVF7uz/0XF0= | |
| 18 | - | github.com/bnema/purego-ffmpeg v0.3.1/go.mod h1:0/PCk5gYU0Dl1taBgiJd8ttR7A3SFSJXeNJBljeqFGc= | |
| 19 | 17 | github.com/bnema/purego-pipewire v0.1.2 h1:U057iOlpQatwlis6nfrZQJVrT3aGDFdagRLUz+ll7Ik= | |
| 20 | 18 | github.com/bnema/purego-pipewire v0.1.2/go.mod h1:kchW7Bkfjc+NkX2IZw3LnlYfQxk6jxhl32Id88u04y8= | |
| 21 | 19 | github.com/bnema/purego-sqlite v0.1.2 h1:dhqfe3KfNRBh7tlrwxL7bf4FPZk2VCajdBESMbrvmoE= | |
| @@ -14,8 +14,6 @@ github.com/bnema/purego v0.11.0-bnema.2 h1:7pzcdOJ8bGtLrCz6V582oyleP6css1ICQoGgj | |||||
| 14 | github.com/bnema/purego v0.11.0-bnema.2/go.mod h1:5BnOIUj+0fvk/vwv45iottFoGfkDT7cnLXnpM2s6Hng= | 14 | github.com/bnema/purego v0.11.0-bnema.2/go.mod h1:5BnOIUj+0fvk/vwv45iottFoGfkDT7cnLXnpM2s6Hng= | ||
| 15 | github.com/bnema/purego-cef v0.11.0 h1:tvHx+DfT3Ubcf/e1vIc4M/rJ5Ad9lDa+y04+63+gchM= | 15 | github.com/bnema/purego-cef v0.11.0 h1:tvHx+DfT3Ubcf/e1vIc4M/rJ5Ad9lDa+y04+63+gchM= | ||
| 16 | github.com/bnema/purego-cef v0.11.0/go.mod h1:AIW4lrXr4f2vesO4WnMDN49yAyfivYHV4x6hxpu5SmM= | 16 | github.com/bnema/purego-cef v0.11.0/go.mod h1:AIW4lrXr4f2vesO4WnMDN49yAyfivYHV4x6hxpu5SmM= | ||
| 17 | - | github.com/bnema/purego-ffmpeg v0.3.1 h1:BWqRQvRXH3Lu7atem71Ai//fOYdNwuFofVF7uz/0XF0= | |||
| 18 | - | github.com/bnema/purego-ffmpeg v0.3.1/go.mod h1:0/PCk5gYU0Dl1taBgiJd8ttR7A3SFSJXeNJBljeqFGc= | |||
| 19 | github.com/bnema/purego-pipewire v0.1.2 h1:U057iOlpQatwlis6nfrZQJVrT3aGDFdagRLUz+ll7Ik= | 17 | github.com/bnema/purego-pipewire v0.1.2 h1:U057iOlpQatwlis6nfrZQJVrT3aGDFdagRLUz+ll7Ik= | ||
| 20 | github.com/bnema/purego-pipewire v0.1.2/go.mod h1:kchW7Bkfjc+NkX2IZw3LnlYfQxk6jxhl32Id88u04y8= | 18 | github.com/bnema/purego-pipewire v0.1.2/go.mod h1:kchW7Bkfjc+NkX2IZw3LnlYfQxk6jxhl32Id88u04y8= | ||
| 21 | github.com/bnema/purego-sqlite v0.1.2 h1:dhqfe3KfNRBh7tlrwxL7bf4FPZk2VCajdBESMbrvmoE= | 19 | github.com/bnema/purego-sqlite v0.1.2 h1:dhqfe3KfNRBh7tlrwxL7bf4FPZk2VCajdBESMbrvmoE= | ||
D internal/application/port/transcoder.go +0 -35
| @@ -1,35 +0,0 @@ | |||
| 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 | - | // Close shuts down all active transcode sessions and releases resources. | |
| 34 | - | Close() | |
| 35 | - | } | |
| @@ -1,35 +0,0 @@ | |||||
| 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 | - | // Close shuts down all active transcode sessions and releases resources. | |||
| 34 | - | Close() | |||
| 35 | - | } | |||
M internal/infrastructure/cef/engine_init.go +0 -5
| @@ -172,12 +172,10 @@ func wireEngine( | |||
| 172 | 172 | return nil, fmt.Errorf("GL loader: %w", err) | |
| 173 | 173 | } | |
| 174 | 174 | ||
| 175 | - | var mediaTranscoder port.MediaTranscoder | |
| 176 | 175 | eng.gl = gl | |
| 177 | 176 | eng.factory = newWebViewFactory(eng, gl, webViewFactoryOptions{ | |
| 178 | 177 | scale: detectHiDPIScale(logger), | |
| 179 | 178 | windowlessFrameRate: windowlessFrameRate, | |
| 180 | - | transcoder: mediaTranscoder, | |
| 181 | 179 | audioOutputFactory: audioFactory, | |
| 182 | 180 | }) | |
| 183 | 181 | eng.pool = newWebViewPool(eng.factory) | |
| @@ -187,7 +185,6 @@ func wireEngine( | |||
| 187 | 185 | ctx, | |
| 188 | 186 | eng, | |
| 189 | 187 | logger, | |
| 190 | - | mediaTranscoder, | |
| 191 | 188 | currentConfigPayload, | |
| 192 | 189 | defaultConfigPayload, | |
| 193 | 190 | ) | |
| @@ -207,7 +204,6 @@ func newEngineSchemeHandler( | |||
| 207 | 204 | ctx context.Context, | |
| 208 | 205 | eng *Engine, | |
| 209 | 206 | logger *zerolog.Logger, | |
| 210 | - | mediaTranscoder port.MediaTranscoder, | |
| 211 | 207 | currentConfigPayload func() ([]byte, error), | |
| 212 | 208 | defaultConfigPayload func() ([]byte, error), | |
| 213 | 209 | ) (*MessageRouter, *dumbSchemeHandler, error) { | |
| @@ -215,7 +211,6 @@ func newEngineSchemeHandler( | |||
| 215 | 211 | schemeHandler, err := newDumbSchemeHandler( | |
| 216 | 212 | ctx, | |
| 217 | 213 | messageRouter, | |
| 218 | - | mediaTranscoder, | |
| 219 | 214 | currentConfigPayload, | |
| 220 | 215 | defaultConfigPayload, | |
| 221 | 216 | ) | |
| @@ -172,12 +172,10 @@ func wireEngine( | |||||
| 172 | return nil, fmt.Errorf("GL loader: %w", err) | 172 | return nil, fmt.Errorf("GL loader: %w", err) | ||
| 173 | } | 173 | } | ||
| 174 | 174 | ||||
| 175 | - | var mediaTranscoder port.MediaTranscoder | |||
| 176 | eng.gl = gl | 175 | eng.gl = gl | ||
| 177 | eng.factory = newWebViewFactory(eng, gl, webViewFactoryOptions{ | 176 | eng.factory = newWebViewFactory(eng, gl, webViewFactoryOptions{ | ||
| 178 | scale: detectHiDPIScale(logger), | 177 | scale: detectHiDPIScale(logger), | ||
| 179 | windowlessFrameRate: windowlessFrameRate, | 178 | windowlessFrameRate: windowlessFrameRate, | ||
| 180 | - | transcoder: mediaTranscoder, | |||
| 181 | audioOutputFactory: audioFactory, | 179 | audioOutputFactory: audioFactory, | ||
| 182 | }) | 180 | }) | ||
| 183 | eng.pool = newWebViewPool(eng.factory) | 181 | eng.pool = newWebViewPool(eng.factory) | ||
| @@ -187,7 +185,6 @@ func wireEngine( | |||||
| 187 | ctx, | 185 | ctx, | ||
| 188 | eng, | 186 | eng, | ||
| 189 | logger, | 187 | logger, | ||
| 190 | - | mediaTranscoder, | |||
| 191 | currentConfigPayload, | 188 | currentConfigPayload, | ||
| 192 | defaultConfigPayload, | 189 | defaultConfigPayload, | ||
| 193 | ) | 190 | ) | ||
| @@ -207,7 +204,6 @@ func newEngineSchemeHandler( | |||||
| 207 | ctx context.Context, | 204 | ctx context.Context, | ||
| 208 | eng *Engine, | 205 | eng *Engine, | ||
| 209 | logger *zerolog.Logger, | 206 | logger *zerolog.Logger, | ||
| 210 | - | mediaTranscoder port.MediaTranscoder, | |||
| 211 | currentConfigPayload func() ([]byte, error), | 207 | currentConfigPayload func() ([]byte, error), | ||
| 212 | defaultConfigPayload func() ([]byte, error), | 208 | defaultConfigPayload func() ([]byte, error), | ||
| 213 | ) (*MessageRouter, *dumbSchemeHandler, error) { | 209 | ) (*MessageRouter, *dumbSchemeHandler, error) { | ||
| @@ -215,7 +211,6 @@ func newEngineSchemeHandler( | |||||
| 215 | schemeHandler, err := newDumbSchemeHandler( | 211 | schemeHandler, err := newDumbSchemeHandler( | ||
| 216 | ctx, | 212 | ctx, | ||
| 217 | messageRouter, | 213 | messageRouter, | ||
| 218 | - | mediaTranscoder, | |||
| 219 | currentConfigPayload, | 214 | currentConfigPayload, | ||
| 220 | defaultConfigPayload, | 215 | defaultConfigPayload, | ||
| 221 | ) | 216 | ) | ||
M internal/infrastructure/cef/factory.go +0 -3
| @@ -25,14 +25,12 @@ type WebViewFactory struct { | |||
| 25 | 25 | scale int32 | |
| 26 | 26 | windowlessFrameRate int32 | |
| 27 | 27 | bgColor atomic.Uint32 // packed ARGB for BrowserSettings.BackgroundColor | |
| 28 | - | transcoder port.MediaTranscoder | |
| 29 | 28 | audioOutputFactory port.AudioOutputFactory | |
| 30 | 29 | } | |
| 31 | 30 | ||
| 32 | 31 | type webViewFactoryOptions struct { | |
| 33 | 32 | scale int32 | |
| 34 | 33 | windowlessFrameRate int32 | |
| 35 | - | transcoder port.MediaTranscoder | |
| 36 | 34 | audioOutputFactory port.AudioOutputFactory | |
| 37 | 35 | } | |
| 38 | 36 | ||
| @@ -62,7 +60,6 @@ func newWebViewFactory(engine *Engine, gl *glLoader, opts webViewFactoryOptions) | |||
| 62 | 60 | gl: gl, | |
| 63 | 61 | scale: opts.scale, | |
| 64 | 62 | windowlessFrameRate: opts.windowlessFrameRate, | |
| 65 | - | transcoder: opts.transcoder, | |
| 66 | 63 | audioOutputFactory: opts.audioOutputFactory, | |
| 67 | 64 | } | |
| 68 | 65 | } | |
| @@ -25,14 +25,12 @@ type WebViewFactory struct { | |||||
| 25 | scale int32 | 25 | scale int32 | ||
| 26 | windowlessFrameRate int32 | 26 | windowlessFrameRate int32 | ||
| 27 | bgColor atomic.Uint32 // packed ARGB for BrowserSettings.BackgroundColor | 27 | bgColor atomic.Uint32 // packed ARGB for BrowserSettings.BackgroundColor | ||
| 28 | - | transcoder port.MediaTranscoder | |||
| 29 | audioOutputFactory port.AudioOutputFactory | 28 | audioOutputFactory port.AudioOutputFactory | ||
| 30 | } | 29 | } | ||
| 31 | 30 | ||||
| 32 | type webViewFactoryOptions struct { | 31 | type webViewFactoryOptions struct { | ||
| 33 | scale int32 | 32 | scale int32 | ||
| 34 | windowlessFrameRate int32 | 33 | windowlessFrameRate int32 | ||
| 35 | - | transcoder port.MediaTranscoder | |||
| 36 | audioOutputFactory port.AudioOutputFactory | 34 | audioOutputFactory port.AudioOutputFactory | ||
| 37 | } | 35 | } | ||
| 38 | 36 | ||||
| @@ -62,7 +60,6 @@ func newWebViewFactory(engine *Engine, gl *glLoader, opts webViewFactoryOptions) | |||||
| 62 | gl: gl, | 60 | gl: gl, | ||
| 63 | scale: opts.scale, | 61 | scale: opts.scale, | ||
| 64 | windowlessFrameRate: opts.windowlessFrameRate, | 62 | windowlessFrameRate: opts.windowlessFrameRate, | ||
| 65 | - | transcoder: opts.transcoder, | |||
| 66 | audioOutputFactory: opts.audioOutputFactory, | 63 | audioOutputFactory: opts.audioOutputFactory, | ||
| 67 | } | 64 | } | ||
| 68 | } | 65 | } | ||
M internal/infrastructure/cef/runtime_config.go +0 -31
| @@ -27,37 +27,6 @@ type HandlerRegistrar func(context.Context, port.WebUIHandlerRouter, port.Handle | |||
| 27 | 27 | ||
| 28 | 28 | type AccentHandlerRegistrar func(context.Context, port.WebUIHandlerRouter, port.AccentKeyHandler) error | |
| 29 | 29 | ||
| 30 | - | type MediaClassifier struct { | |
| 31 | - | IsProprietaryVideoMIME func(string) bool | |
| 32 | - | IsOpenVideoMIME func(string) bool | |
| 33 | - | IsStreamingManifestMIME func(string) bool | |
| 34 | - | IsStreamingManifestURL func(string) bool | |
| 35 | - | IsEagerTranscodeURL func(string) bool | |
| 36 | - | ParseSyntheticTranscodeURL func(string) (string, string, string, bool) | |
| 37 | - | } | |
| 38 | - | ||
| 39 | - | func (m MediaClassifier) normalize() MediaClassifier { | |
| 40 | - | if m.IsProprietaryVideoMIME == nil { | |
| 41 | - | m.IsProprietaryVideoMIME = func(string) bool { return false } | |
| 42 | - | } | |
| 43 | - | if m.IsOpenVideoMIME == nil { | |
| 44 | - | m.IsOpenVideoMIME = func(string) bool { return false } | |
| 45 | - | } | |
| 46 | - | if m.IsStreamingManifestMIME == nil { | |
| 47 | - | m.IsStreamingManifestMIME = func(string) bool { return false } | |
| 48 | - | } | |
| 49 | - | if m.IsStreamingManifestURL == nil { | |
| 50 | - | m.IsStreamingManifestURL = func(string) bool { return false } | |
| 51 | - | } | |
| 52 | - | if m.IsEagerTranscodeURL == nil { | |
| 53 | - | m.IsEagerTranscodeURL = func(string) bool { return false } | |
| 54 | - | } | |
| 55 | - | if m.ParseSyntheticTranscodeURL == nil { | |
| 56 | - | m.ParseSyntheticTranscodeURL = func(string) (string, string, string, bool) { return "", "", "", false } | |
| 57 | - | } | |
| 58 | - | return m | |
| 59 | - | } | |
| 60 | - | ||
| 61 | 30 | type EngineDependencies struct { | |
| 62 | 31 | RegisterHandlers HandlerRegistrar | |
| 63 | 32 | RegisterAccentHandlers AccentHandlerRegistrar | |
| @@ -27,37 +27,6 @@ type HandlerRegistrar func(context.Context, port.WebUIHandlerRouter, port.Handle | |||||
| 27 | 27 | ||||
| 28 | type AccentHandlerRegistrar func(context.Context, port.WebUIHandlerRouter, port.AccentKeyHandler) error | 28 | type AccentHandlerRegistrar func(context.Context, port.WebUIHandlerRouter, port.AccentKeyHandler) error | ||
| 29 | 29 | ||||
| 30 | - | type MediaClassifier struct { | |||
| 31 | - | IsProprietaryVideoMIME func(string) bool | |||
| 32 | - | IsOpenVideoMIME func(string) bool | |||
| 33 | - | IsStreamingManifestMIME func(string) bool | |||
| 34 | - | IsStreamingManifestURL func(string) bool | |||
| 35 | - | IsEagerTranscodeURL func(string) bool | |||
| 36 | - | ParseSyntheticTranscodeURL func(string) (string, string, string, bool) | |||
| 37 | - | } | |||
| 38 | - | ||||
| 39 | - | func (m MediaClassifier) normalize() MediaClassifier { | |||
| 40 | - | if m.IsProprietaryVideoMIME == nil { | |||
| 41 | - | m.IsProprietaryVideoMIME = func(string) bool { return false } | |||
| 42 | - | } | |||
| 43 | - | if m.IsOpenVideoMIME == nil { | |||
| 44 | - | m.IsOpenVideoMIME = func(string) bool { return false } | |||
| 45 | - | } | |||
| 46 | - | if m.IsStreamingManifestMIME == nil { | |||
| 47 | - | m.IsStreamingManifestMIME = func(string) bool { return false } | |||
| 48 | - | } | |||
| 49 | - | if m.IsStreamingManifestURL == nil { | |||
| 50 | - | m.IsStreamingManifestURL = func(string) bool { return false } | |||
| 51 | - | } | |||
| 52 | - | if m.IsEagerTranscodeURL == nil { | |||
| 53 | - | m.IsEagerTranscodeURL = func(string) bool { return false } | |||
| 54 | - | } | |||
| 55 | - | if m.ParseSyntheticTranscodeURL == nil { | |||
| 56 | - | m.ParseSyntheticTranscodeURL = func(string) (string, string, string, bool) { return "", "", "", false } | |||
| 57 | - | } | |||
| 58 | - | return m | |||
| 59 | - | } | |||
| 60 | - | ||||
| 61 | type EngineDependencies struct { | 30 | type EngineDependencies struct { | ||
| 62 | RegisterHandlers HandlerRegistrar | 31 | RegisterHandlers HandlerRegistrar | ||
| 63 | RegisterAccentHandlers AccentHandlerRegistrar | 32 | RegisterAccentHandlers AccentHandlerRegistrar | ||
M internal/infrastructure/cef/scheme_handler.go +0 -3
| @@ -56,7 +56,6 @@ var cefNewResourceHandler = purecef.NewResourceHandler | |||
| 56 | 56 | type dumbSchemeHandler struct { | |
| 57 | 57 | ctx context.Context | |
| 58 | 58 | messageRouter *MessageRouter | |
| 59 | - | transcoder port.MediaTranscoder | |
| 60 | 59 | faviconService port.FaviconService | |
| 61 | 60 | assets embed.FS | |
| 62 | 61 | assetsSet bool | |
| @@ -92,7 +91,6 @@ type dumbSchemeHandler struct { | |||
| 92 | 91 | func newDumbSchemeHandler( | |
| 93 | 92 | ctx context.Context, | |
| 94 | 93 | router *MessageRouter, | |
| 95 | - | transcoder port.MediaTranscoder, | |
| 96 | 94 | currentConfigPayload func() ([]byte, error), | |
| 97 | 95 | defaultConfigPayload func() ([]byte, error), | |
| 98 | 96 | ) (*dumbSchemeHandler, error) { | |
| @@ -107,7 +105,6 @@ func newDumbSchemeHandler( | |||
| 107 | 105 | return &dumbSchemeHandler{ | |
| 108 | 106 | ctx: ctx, | |
| 109 | 107 | messageRouter: router, | |
| 110 | - | transcoder: transcoder, | |
| 111 | 108 | assetDir: systemviewsAssetDir, | |
| 112 | 109 | logger: log.With().Str("component", "scheme-handler").Logger(), | |
| 113 | 110 | currentConfigPayload: currentConfigPayload, | |
| @@ -56,7 +56,6 @@ var cefNewResourceHandler = purecef.NewResourceHandler | |||||
| 56 | type dumbSchemeHandler struct { | 56 | type dumbSchemeHandler struct { | ||
| 57 | ctx context.Context | 57 | ctx context.Context | ||
| 58 | messageRouter *MessageRouter | 58 | messageRouter *MessageRouter | ||
| 59 | - | transcoder port.MediaTranscoder | |||
| 60 | faviconService port.FaviconService | 59 | faviconService port.FaviconService | ||
| 61 | assets embed.FS | 60 | assets embed.FS | ||
| 62 | assetsSet bool | 61 | assetsSet bool | ||
| @@ -92,7 +91,6 @@ type dumbSchemeHandler struct { | |||||
| 92 | func newDumbSchemeHandler( | 91 | func newDumbSchemeHandler( | ||
| 93 | ctx context.Context, | 92 | ctx context.Context, | ||
| 94 | router *MessageRouter, | 93 | router *MessageRouter, | ||
| 95 | - | transcoder port.MediaTranscoder, | |||
| 96 | currentConfigPayload func() ([]byte, error), | 94 | currentConfigPayload func() ([]byte, error), | ||
| 97 | defaultConfigPayload func() ([]byte, error), | 95 | defaultConfigPayload func() ([]byte, error), | ||
| 98 | ) (*dumbSchemeHandler, error) { | 96 | ) (*dumbSchemeHandler, error) { | ||
| @@ -107,7 +105,6 @@ func newDumbSchemeHandler( | |||||
| 107 | return &dumbSchemeHandler{ | 105 | return &dumbSchemeHandler{ | ||
| 108 | ctx: ctx, | 106 | ctx: ctx, | ||
| 109 | messageRouter: router, | 107 | messageRouter: router, | ||
| 110 | - | transcoder: transcoder, | |||
| 111 | assetDir: systemviewsAssetDir, | 108 | assetDir: systemviewsAssetDir, | ||
| 112 | logger: log.With().Str("component", "scheme-handler").Logger(), | 109 | logger: log.With().Str("component", "scheme-handler").Logger(), | ||
| 113 | currentConfigPayload: currentConfigPayload, | 110 | currentConfigPayload: currentConfigPayload, | ||
M internal/infrastructure/cef/scheme_handler_test.go +22 -22
| @@ -72,21 +72,21 @@ func TestResolveConfigPayload_NilBuilderFails(t *testing.T) { | |||
| 72 | 72 | } | |
| 73 | 73 | ||
| 74 | 74 | func TestNewDumbSchemeHandler_NilCurrentConfigPayloadFails(t *testing.T) { | |
| 75 | - | h, err := newDumbSchemeHandler(context.Background(), nil, nil, nil, func() ([]byte, error) { return nil, nil }) | |
| 75 | + | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return nil, nil }) | |
| 76 | 76 | require.Error(t, err) | |
| 77 | 77 | require.Nil(t, h) | |
| 78 | 78 | require.Contains(t, err.Error(), "current config payload builder") | |
| 79 | 79 | } | |
| 80 | 80 | ||
| 81 | 81 | func TestNewDumbSchemeHandler_NilDefaultConfigPayloadFails(t *testing.T) { | |
| 82 | - | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return nil, nil }, nil) | |
| 82 | + | h, err := newDumbSchemeHandler(context.Background(), nil, func() ([]byte, error) { return nil, nil }, nil) | |
| 83 | 83 | require.Error(t, err) | |
| 84 | 84 | require.Nil(t, h) | |
| 85 | 85 | require.Contains(t, err.Error(), "default config payload builder") | |
| 86 | 86 | } | |
| 87 | 87 | ||
| 88 | 88 | func TestRejectForbiddenAPIOrigin_AllowsInternalOrigin(t *testing.T) { | |
| 89 | - | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |
| 89 | + | h, err := newDumbSchemeHandler(context.Background(), nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |
| 90 | 90 | require.NoError(t, err) | |
| 91 | 91 | ||
| 92 | 92 | request := cefmocks.NewMockRequest(t) | |
| @@ -100,7 +100,7 @@ func TestRejectForbiddenAPIOrigin_RejectsExternalOrigin(t *testing.T) { | |||
| 100 | 100 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |
| 101 | 101 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |
| 102 | 102 | ||
| 103 | - | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |
| 103 | + | h, err := newDumbSchemeHandler(context.Background(), nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |
| 104 | 104 | require.NoError(t, err) | |
| 105 | 105 | ||
| 106 | 106 | request := cefmocks.NewMockRequest(t) | |
| @@ -124,7 +124,7 @@ func TestRejectUntrustedConfigRequesterRequiresTrustedOriginOrReferrer(t *testin | |||
| 124 | 124 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |
| 125 | 125 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |
| 126 | 126 | ||
| 127 | - | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |
| 127 | + | h, err := newDumbSchemeHandler(context.Background(), nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |
| 128 | 128 | require.NoError(t, err) | |
| 129 | 129 | ||
| 130 | 130 | trustedOrigin := cefmocks.NewMockRequest(t) | |
| @@ -162,7 +162,7 @@ func TestHandleMessageAPIRequiresTrustedOriginOrReferrer(t *testing.T) { | |||
| 162 | 162 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |
| 163 | 163 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |
| 164 | 164 | ||
| 165 | - | h, err := newDumbSchemeHandler(context.Background(), NewMessageRouter(context.Background()), nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |
| 165 | + | h, err := newDumbSchemeHandler(context.Background(), NewMessageRouter(context.Background()), func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |
| 166 | 166 | require.NoError(t, err) | |
| 167 | 167 | ||
| 168 | 168 | trusted := cefmocks.NewMockRequest(t) | |
| @@ -194,7 +194,7 @@ func TestHandleConfigAPIUsesPrivateNoCORSHeaders(t *testing.T) { | |||
| 194 | 194 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |
| 195 | 195 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |
| 196 | 196 | ||
| 197 | - | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{"ok":true}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |
| 197 | + | h, err := newDumbSchemeHandler(context.Background(), nil, func() ([]byte, error) { return []byte(`{"ok":true}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |
| 198 | 198 | require.NoError(t, err) | |
| 199 | 199 | ||
| 200 | 200 | response := cefmocks.NewMockResponse(t) | |
| @@ -214,7 +214,7 @@ func TestHandleConfigAPIOptionsUsesPrivateNoCORSHeaders(t *testing.T) { | |||
| 214 | 214 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |
| 215 | 215 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |
| 216 | 216 | ||
| 217 | - | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |
| 217 | + | h, err := newDumbSchemeHandler(context.Background(), nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |
| 218 | 218 | require.NoError(t, err) | |
| 219 | 219 | ||
| 220 | 220 | request := cefmocks.NewMockRequest(t) | |
| @@ -254,7 +254,7 @@ func TestIsTrustedSystemviewURL(t *testing.T) { | |||
| 254 | 254 | } | |
| 255 | 255 | ||
| 256 | 256 | func TestRejectUntrustedFaviconRequesterAllowsTrustedOriginWithoutReferrer(t *testing.T) { | |
| 257 | - | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |
| 257 | + | h, err := newDumbSchemeHandler(context.Background(), nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |
| 258 | 258 | require.NoError(t, err) | |
| 259 | 259 | ||
| 260 | 260 | request := cefmocks.NewMockRequest(t) | |
| @@ -268,7 +268,7 @@ func TestRejectUntrustedFaviconRequesterRejectsUntrustedOriginEvenWithTrustedRef | |||
| 268 | 268 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |
| 269 | 269 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |
| 270 | 270 | ||
| 271 | - | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |
| 271 | + | h, err := newDumbSchemeHandler(context.Background(), nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |
| 272 | 272 | require.NoError(t, err) | |
| 273 | 273 | ||
| 274 | 274 | request := cefmocks.NewMockRequest(t) | |
| @@ -282,7 +282,7 @@ func TestRejectUntrustedFaviconRequesterRequiresTrustedReferrerWhenOriginAbsent( | |||
| 282 | 282 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |
| 283 | 283 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |
| 284 | 284 | ||
| 285 | - | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |
| 285 | + | h, err := newDumbSchemeHandler(context.Background(), nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |
| 286 | 286 | require.NoError(t, err) | |
| 287 | 287 | ||
| 288 | 288 | trusted := cefmocks.NewMockRequest(t) | |
| @@ -302,7 +302,7 @@ func TestHandleFaviconAPIDefersFaviconDiskChecksUntilResourceOpen(t *testing.T) | |||
| 302 | 302 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |
| 303 | 303 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |
| 304 | 304 | ||
| 305 | - | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |
| 305 | + | h, err := newDumbSchemeHandler(context.Background(), nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |
| 306 | 306 | require.NoError(t, err) | |
| 307 | 307 | h.setFaviconService(panicFaviconService{}) | |
| 308 | 308 | ||
| @@ -342,7 +342,7 @@ func TestSchemeHandler_APIClipboardSetPathWritesClipboardPayload(t *testing.T) { | |||
| 342 | 342 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |
| 343 | 343 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |
| 344 | 344 | ||
| 345 | - | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |
| 345 | + | h, err := newDumbSchemeHandler(context.Background(), nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |
| 346 | 346 | require.NoError(t, err) | |
| 347 | 347 | ||
| 348 | 348 | const bridgeNonce = "bridge-nonce" | |
| @@ -379,7 +379,7 @@ func TestSchemeHandler_APIFocusSyncRejectsRequestsWithoutTrustedBridgeHeader(t * | |||
| 379 | 379 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |
| 380 | 380 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |
| 381 | 381 | ||
| 382 | - | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |
| 382 | + | h, err := newDumbSchemeHandler(context.Background(), nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |
| 383 | 383 | require.NoError(t, err) | |
| 384 | 384 | ||
| 385 | 385 | request := cefmocks.NewMockRequest(t) | |
| @@ -404,7 +404,7 @@ func TestSchemeHandler_APIFocusSyncInvokesEditableFocusCallback(t *testing.T) { | |||
| 404 | 404 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |
| 405 | 405 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |
| 406 | 406 | ||
| 407 | - | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |
| 407 | + | h, err := newDumbSchemeHandler(context.Background(), nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |
| 408 | 408 | require.NoError(t, err) | |
| 409 | 409 | ||
| 410 | 410 | const bridgeNonce = "bridge-nonce" | |
| @@ -440,7 +440,7 @@ func TestSchemeHandler_Create_ConceptualAPIRequestBypassesRedirect(t *testing.T) | |||
| 440 | 440 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |
| 441 | 441 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |
| 442 | 442 | ||
| 443 | - | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |
| 443 | + | h, err := newDumbSchemeHandler(context.Background(), nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |
| 444 | 444 | require.NoError(t, err) | |
| 445 | 445 | ||
| 446 | 446 | const bridgeNonce = "bridge-nonce" | |
| @@ -479,7 +479,7 @@ func TestSchemeHandler_APIClipboardSetRejectsInvalidBridgeNonce(t *testing.T) { | |||
| 479 | 479 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |
| 480 | 480 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |
| 481 | 481 | ||
| 482 | - | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |
| 482 | + | h, err := newDumbSchemeHandler(context.Background(), nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |
| 483 | 483 | require.NoError(t, err) | |
| 484 | 484 | ||
| 485 | 485 | browser := cefmocks.NewMockBrowser(t) | |
| @@ -507,7 +507,7 @@ func TestSchemeHandler_APIPopupOpenInvokesPopupCallback(t *testing.T) { | |||
| 507 | 507 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |
| 508 | 508 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |
| 509 | 509 | ||
| 510 | - | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |
| 510 | + | h, err := newDumbSchemeHandler(context.Background(), nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |
| 511 | 511 | require.NoError(t, err) | |
| 512 | 512 | ||
| 513 | 513 | const bridgeNonce = "bridge-nonce" | |
| @@ -551,7 +551,7 @@ func TestSchemeHandler_APIPopupNavigateInvokesPopupCallback(t *testing.T) { | |||
| 551 | 551 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |
| 552 | 552 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |
| 553 | 553 | ||
| 554 | - | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |
| 554 | + | h, err := newDumbSchemeHandler(context.Background(), nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |
| 555 | 555 | require.NoError(t, err) | |
| 556 | 556 | ||
| 557 | 557 | const bridgeNonce = "bridge-nonce" | |
| @@ -592,7 +592,7 @@ func TestSchemeHandler_APIPopupCloseInvokesPopupCallback(t *testing.T) { | |||
| 592 | 592 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |
| 593 | 593 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |
| 594 | 594 | ||
| 595 | - | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |
| 595 | + | h, err := newDumbSchemeHandler(context.Background(), nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |
| 596 | 596 | require.NoError(t, err) | |
| 597 | 597 | ||
| 598 | 598 | const bridgeNonce = "bridge-nonce" | |
| @@ -690,7 +690,7 @@ func TestSchemeHandler_APIPopupBridgeRejectsInvalidPayloads(t *testing.T) { | |||
| 690 | 690 | ||
| 691 | 691 | for _, test := range tests { | |
| 692 | 692 | t.Run(test.name, func(t *testing.T) { | |
| 693 | - | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |
| 693 | + | h, err := newDumbSchemeHandler(context.Background(), nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |
| 694 | 694 | require.NoError(t, err) | |
| 695 | 695 | ||
| 696 | 696 | const bridgeNonce = "bridge-nonce" | |
| @@ -729,7 +729,7 @@ func TestSchemeHandler_APIOptionsReturnsCORSPreflightHeaders(t *testing.T) { | |||
| 729 | 729 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |
| 730 | 730 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |
| 731 | 731 | ||
| 732 | - | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |
| 732 | + | h, err := newDumbSchemeHandler(context.Background(), nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |
| 733 | 733 | require.NoError(t, err) | |
| 734 | 734 | ||
| 735 | 735 | request := cefmocks.NewMockRequest(t) | |
| @@ -72,21 +72,21 @@ func TestResolveConfigPayload_NilBuilderFails(t *testing.T) { | |||||
| 72 | } | 72 | } | ||
| 73 | 73 | ||||
| 74 | func TestNewDumbSchemeHandler_NilCurrentConfigPayloadFails(t *testing.T) { | 74 | func TestNewDumbSchemeHandler_NilCurrentConfigPayloadFails(t *testing.T) { | ||
| 75 | - | h, err := newDumbSchemeHandler(context.Background(), nil, nil, nil, func() ([]byte, error) { return nil, nil }) | 75 | + | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return nil, nil }) |
| 76 | require.Error(t, err) | 76 | require.Error(t, err) | ||
| 77 | require.Nil(t, h) | 77 | require.Nil(t, h) | ||
| 78 | require.Contains(t, err.Error(), "current config payload builder") | 78 | require.Contains(t, err.Error(), "current config payload builder") | ||
| 79 | } | 79 | } | ||
| 80 | 80 | ||||
| 81 | func TestNewDumbSchemeHandler_NilDefaultConfigPayloadFails(t *testing.T) { | 81 | func TestNewDumbSchemeHandler_NilDefaultConfigPayloadFails(t *testing.T) { | ||
| 82 | - | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return nil, nil }, nil) | 82 | + | h, err := newDumbSchemeHandler(context.Background(), nil, func() ([]byte, error) { return nil, nil }, nil) |
| 83 | require.Error(t, err) | 83 | require.Error(t, err) | ||
| 84 | require.Nil(t, h) | 84 | require.Nil(t, h) | ||
| 85 | require.Contains(t, err.Error(), "default config payload builder") | 85 | require.Contains(t, err.Error(), "default config payload builder") | ||
| 86 | } | 86 | } | ||
| 87 | 87 | ||||
| 88 | func TestRejectForbiddenAPIOrigin_AllowsInternalOrigin(t *testing.T) { | 88 | func TestRejectForbiddenAPIOrigin_AllowsInternalOrigin(t *testing.T) { | ||
| 89 | - | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | 89 | + | h, err := newDumbSchemeHandler(context.Background(), nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) |
| 90 | require.NoError(t, err) | 90 | require.NoError(t, err) | ||
| 91 | 91 | ||||
| 92 | request := cefmocks.NewMockRequest(t) | 92 | request := cefmocks.NewMockRequest(t) | ||
| @@ -100,7 +100,7 @@ func TestRejectForbiddenAPIOrigin_RejectsExternalOrigin(t *testing.T) { | |||||
| 100 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | 100 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | ||
| 101 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | 101 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | ||
| 102 | 102 | ||||
| 103 | - | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | 103 | + | h, err := newDumbSchemeHandler(context.Background(), nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) |
| 104 | require.NoError(t, err) | 104 | require.NoError(t, err) | ||
| 105 | 105 | ||||
| 106 | request := cefmocks.NewMockRequest(t) | 106 | request := cefmocks.NewMockRequest(t) | ||
| @@ -124,7 +124,7 @@ func TestRejectUntrustedConfigRequesterRequiresTrustedOriginOrReferrer(t *testin | |||||
| 124 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | 124 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | ||
| 125 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | 125 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | ||
| 126 | 126 | ||||
| 127 | - | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | 127 | + | h, err := newDumbSchemeHandler(context.Background(), nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) |
| 128 | require.NoError(t, err) | 128 | require.NoError(t, err) | ||
| 129 | 129 | ||||
| 130 | trustedOrigin := cefmocks.NewMockRequest(t) | 130 | trustedOrigin := cefmocks.NewMockRequest(t) | ||
| @@ -162,7 +162,7 @@ func TestHandleMessageAPIRequiresTrustedOriginOrReferrer(t *testing.T) { | |||||
| 162 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | 162 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | ||
| 163 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | 163 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | ||
| 164 | 164 | ||||
| 165 | - | h, err := newDumbSchemeHandler(context.Background(), NewMessageRouter(context.Background()), nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | 165 | + | h, err := newDumbSchemeHandler(context.Background(), NewMessageRouter(context.Background()), func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) |
| 166 | require.NoError(t, err) | 166 | require.NoError(t, err) | ||
| 167 | 167 | ||||
| 168 | trusted := cefmocks.NewMockRequest(t) | 168 | trusted := cefmocks.NewMockRequest(t) | ||
| @@ -194,7 +194,7 @@ func TestHandleConfigAPIUsesPrivateNoCORSHeaders(t *testing.T) { | |||||
| 194 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | 194 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | ||
| 195 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | 195 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | ||
| 196 | 196 | ||||
| 197 | - | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{"ok":true}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | 197 | + | h, err := newDumbSchemeHandler(context.Background(), nil, func() ([]byte, error) { return []byte(`{"ok":true}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) |
| 198 | require.NoError(t, err) | 198 | require.NoError(t, err) | ||
| 199 | 199 | ||||
| 200 | response := cefmocks.NewMockResponse(t) | 200 | response := cefmocks.NewMockResponse(t) | ||
| @@ -214,7 +214,7 @@ func TestHandleConfigAPIOptionsUsesPrivateNoCORSHeaders(t *testing.T) { | |||||
| 214 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | 214 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | ||
| 215 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | 215 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | ||
| 216 | 216 | ||||
| 217 | - | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | 217 | + | h, err := newDumbSchemeHandler(context.Background(), nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) |
| 218 | require.NoError(t, err) | 218 | require.NoError(t, err) | ||
| 219 | 219 | ||||
| 220 | request := cefmocks.NewMockRequest(t) | 220 | request := cefmocks.NewMockRequest(t) | ||
| @@ -254,7 +254,7 @@ func TestIsTrustedSystemviewURL(t *testing.T) { | |||||
| 254 | } | 254 | } | ||
| 255 | 255 | ||||
| 256 | func TestRejectUntrustedFaviconRequesterAllowsTrustedOriginWithoutReferrer(t *testing.T) { | 256 | func TestRejectUntrustedFaviconRequesterAllowsTrustedOriginWithoutReferrer(t *testing.T) { | ||
| 257 | - | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | 257 | + | h, err := newDumbSchemeHandler(context.Background(), nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) |
| 258 | require.NoError(t, err) | 258 | require.NoError(t, err) | ||
| 259 | 259 | ||||
| 260 | request := cefmocks.NewMockRequest(t) | 260 | request := cefmocks.NewMockRequest(t) | ||
| @@ -268,7 +268,7 @@ func TestRejectUntrustedFaviconRequesterRejectsUntrustedOriginEvenWithTrustedRef | |||||
| 268 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | 268 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | ||
| 269 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | 269 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | ||
| 270 | 270 | ||||
| 271 | - | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | 271 | + | h, err := newDumbSchemeHandler(context.Background(), nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) |
| 272 | require.NoError(t, err) | 272 | require.NoError(t, err) | ||
| 273 | 273 | ||||
| 274 | request := cefmocks.NewMockRequest(t) | 274 | request := cefmocks.NewMockRequest(t) | ||
| @@ -282,7 +282,7 @@ func TestRejectUntrustedFaviconRequesterRequiresTrustedReferrerWhenOriginAbsent( | |||||
| 282 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | 282 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | ||
| 283 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | 283 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | ||
| 284 | 284 | ||||
| 285 | - | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | 285 | + | h, err := newDumbSchemeHandler(context.Background(), nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) |
| 286 | require.NoError(t, err) | 286 | require.NoError(t, err) | ||
| 287 | 287 | ||||
| 288 | trusted := cefmocks.NewMockRequest(t) | 288 | trusted := cefmocks.NewMockRequest(t) | ||
| @@ -302,7 +302,7 @@ func TestHandleFaviconAPIDefersFaviconDiskChecksUntilResourceOpen(t *testing.T) | |||||
| 302 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | 302 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | ||
| 303 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | 303 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | ||
| 304 | 304 | ||||
| 305 | - | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | 305 | + | h, err := newDumbSchemeHandler(context.Background(), nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) |
| 306 | require.NoError(t, err) | 306 | require.NoError(t, err) | ||
| 307 | h.setFaviconService(panicFaviconService{}) | 307 | h.setFaviconService(panicFaviconService{}) | ||
| 308 | 308 | ||||
| @@ -342,7 +342,7 @@ func TestSchemeHandler_APIClipboardSetPathWritesClipboardPayload(t *testing.T) { | |||||
| 342 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | 342 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | ||
| 343 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | 343 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | ||
| 344 | 344 | ||||
| 345 | - | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | 345 | + | h, err := newDumbSchemeHandler(context.Background(), nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) |
| 346 | require.NoError(t, err) | 346 | require.NoError(t, err) | ||
| 347 | 347 | ||||
| 348 | const bridgeNonce = "bridge-nonce" | 348 | const bridgeNonce = "bridge-nonce" | ||
| @@ -379,7 +379,7 @@ func TestSchemeHandler_APIFocusSyncRejectsRequestsWithoutTrustedBridgeHeader(t * | |||||
| 379 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | 379 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | ||
| 380 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | 380 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | ||
| 381 | 381 | ||||
| 382 | - | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | 382 | + | h, err := newDumbSchemeHandler(context.Background(), nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) |
| 383 | require.NoError(t, err) | 383 | require.NoError(t, err) | ||
| 384 | 384 | ||||
| 385 | request := cefmocks.NewMockRequest(t) | 385 | request := cefmocks.NewMockRequest(t) | ||
| @@ -404,7 +404,7 @@ func TestSchemeHandler_APIFocusSyncInvokesEditableFocusCallback(t *testing.T) { | |||||
| 404 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | 404 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | ||
| 405 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | 405 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | ||
| 406 | 406 | ||||
| 407 | - | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | 407 | + | h, err := newDumbSchemeHandler(context.Background(), nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) |
| 408 | require.NoError(t, err) | 408 | require.NoError(t, err) | ||
| 409 | 409 | ||||
| 410 | const bridgeNonce = "bridge-nonce" | 410 | const bridgeNonce = "bridge-nonce" | ||
| @@ -440,7 +440,7 @@ func TestSchemeHandler_Create_ConceptualAPIRequestBypassesRedirect(t *testing.T) | |||||
| 440 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | 440 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | ||
| 441 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | 441 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | ||
| 442 | 442 | ||||
| 443 | - | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | 443 | + | h, err := newDumbSchemeHandler(context.Background(), nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) |
| 444 | require.NoError(t, err) | 444 | require.NoError(t, err) | ||
| 445 | 445 | ||||
| 446 | const bridgeNonce = "bridge-nonce" | 446 | const bridgeNonce = "bridge-nonce" | ||
| @@ -479,7 +479,7 @@ func TestSchemeHandler_APIClipboardSetRejectsInvalidBridgeNonce(t *testing.T) { | |||||
| 479 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | 479 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | ||
| 480 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | 480 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | ||
| 481 | 481 | ||||
| 482 | - | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | 482 | + | h, err := newDumbSchemeHandler(context.Background(), nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) |
| 483 | require.NoError(t, err) | 483 | require.NoError(t, err) | ||
| 484 | 484 | ||||
| 485 | browser := cefmocks.NewMockBrowser(t) | 485 | browser := cefmocks.NewMockBrowser(t) | ||
| @@ -507,7 +507,7 @@ func TestSchemeHandler_APIPopupOpenInvokesPopupCallback(t *testing.T) { | |||||
| 507 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | 507 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | ||
| 508 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | 508 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | ||
| 509 | 509 | ||||
| 510 | - | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | 510 | + | h, err := newDumbSchemeHandler(context.Background(), nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) |
| 511 | require.NoError(t, err) | 511 | require.NoError(t, err) | ||
| 512 | 512 | ||||
| 513 | const bridgeNonce = "bridge-nonce" | 513 | const bridgeNonce = "bridge-nonce" | ||
| @@ -551,7 +551,7 @@ func TestSchemeHandler_APIPopupNavigateInvokesPopupCallback(t *testing.T) { | |||||
| 551 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | 551 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | ||
| 552 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | 552 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | ||
| 553 | 553 | ||||
| 554 | - | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | 554 | + | h, err := newDumbSchemeHandler(context.Background(), nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) |
| 555 | require.NoError(t, err) | 555 | require.NoError(t, err) | ||
| 556 | 556 | ||||
| 557 | const bridgeNonce = "bridge-nonce" | 557 | const bridgeNonce = "bridge-nonce" | ||
| @@ -592,7 +592,7 @@ func TestSchemeHandler_APIPopupCloseInvokesPopupCallback(t *testing.T) { | |||||
| 592 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | 592 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | ||
| 593 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | 593 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | ||
| 594 | 594 | ||||
| 595 | - | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | 595 | + | h, err := newDumbSchemeHandler(context.Background(), nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) |
| 596 | require.NoError(t, err) | 596 | require.NoError(t, err) | ||
| 597 | 597 | ||||
| 598 | const bridgeNonce = "bridge-nonce" | 598 | const bridgeNonce = "bridge-nonce" | ||
| @@ -690,7 +690,7 @@ func TestSchemeHandler_APIPopupBridgeRejectsInvalidPayloads(t *testing.T) { | |||||
| 690 | 690 | ||||
| 691 | for _, test := range tests { | 691 | for _, test := range tests { | ||
| 692 | t.Run(test.name, func(t *testing.T) { | 692 | t.Run(test.name, func(t *testing.T) { | ||
| 693 | - | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | 693 | + | h, err := newDumbSchemeHandler(context.Background(), nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) |
| 694 | require.NoError(t, err) | 694 | require.NoError(t, err) | ||
| 695 | 695 | ||||
| 696 | const bridgeNonce = "bridge-nonce" | 696 | const bridgeNonce = "bridge-nonce" | ||
| @@ -729,7 +729,7 @@ func TestSchemeHandler_APIOptionsReturnsCORSPreflightHeaders(t *testing.T) { | |||||
| 729 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | 729 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | ||
| 730 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | 730 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | ||
| 731 | 731 | ||||
| 732 | - | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | 732 | + | h, err := newDumbSchemeHandler(context.Background(), nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) |
| 733 | require.NoError(t, err) | 733 | require.NoError(t, err) | ||
| 734 | 734 | ||||
| 735 | request := cefmocks.NewMockRequest(t) | 735 | request := cefmocks.NewMockRequest(t) | ||
D internal/infrastructure/cef/transcoding_handler.go +0 -490
| @@ -1,490 +0,0 @@ | |||
| 1 | - | package cef | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "context" | |
| 5 | - | "errors" | |
| 6 | - | "io" | |
| 7 | - | "sync" | |
| 8 | - | "time" | |
| 9 | - | "unsafe" | |
| 10 | - | ||
| 11 | - | purecef "github.com/bnema/purego-cef/cef" | |
| 12 | - | ||
| 13 | - | "github.com/bnema/dumber/internal/application/port" | |
| 14 | - | "github.com/bnema/dumber/internal/logging" | |
| 15 | - | "github.com/rs/zerolog" | |
| 16 | - | ) | |
| 17 | - | ||
| 18 | - | // --------------------------------------------------------------------------- | |
| 19 | - | // Compile-time interface checks | |
| 20 | - | // --------------------------------------------------------------------------- | |
| 21 | - | ||
| 22 | - | var _ purecef.ResourceRequestHandler = (*transcodingRequestHandler)(nil) | |
| 23 | - | ||
| 24 | - | const ( | |
| 25 | - | maxTranscodingURLLength = 240 | |
| 26 | - | httpStatusOK = 200 | |
| 27 | - | httpStatusForbidden = 403 | |
| 28 | - | httpStatusBadGateway = 502 | |
| 29 | - | ) | |
| 30 | - | ||
| 31 | - | // transcodingResourceHandler is checked indirectly: purecef.NewResourceHandler | |
| 32 | - | // accepts a purecef.ResourceHandler, and the struct implements all methods. | |
| 33 | - | ||
| 34 | - | // --------------------------------------------------------------------------- | |
| 35 | - | // transcodingResourceHandler — serves a transcoded WebM stream to CEF | |
| 36 | - | // --------------------------------------------------------------------------- | |
| 37 | - | ||
| 38 | - | type transcodingResourceHandler struct { | |
| 39 | - | transcoder port.MediaTranscoder | |
| 40 | - | sourceURL string | |
| 41 | - | headers map[string]string | |
| 42 | - | session port.TranscodeSession | |
| 43 | - | ctx context.Context | |
| 44 | - | cancel context.CancelFunc | |
| 45 | - | logf func() zerolog.Logger | |
| 46 | - | validateSource func(context.Context) error | |
| 47 | - | mu sync.Mutex | |
| 48 | - | startErr error | |
| 49 | - | startErrStatus int | |
| 50 | - | totalBytes int64 | |
| 51 | - | } | |
| 52 | - | ||
| 53 | - | // Open starts the transcode session asynchronously. CEF will call | |
| 54 | - | // GetResponseHeaders and Read once the callback fires. | |
| 55 | - | func (rh *transcodingResourceHandler) Open( | |
| 56 | - | _ purecef.Request, handleRequest *int32, callback purecef.Callback, | |
| 57 | - | ) int32 { | |
| 58 | - | // Signal async handling: set handleRequest = 0. | |
| 59 | - | if handleRequest != nil { | |
| 60 | - | *handleRequest = 0 | |
| 61 | - | } | |
| 62 | - | ||
| 63 | - | log := rh.logger() | |
| 64 | - | sourceURL := logging.TruncateURL(rh.sourceURL, maxTranscodingURLLength) | |
| 65 | - | log.Info(). | |
| 66 | - | Str("source_url", sourceURL). | |
| 67 | - | Int("forwarded_header_count", len(rh.headers)). | |
| 68 | - | Msg("cef: starting transcoding resource stream") | |
| 69 | - | ||
| 70 | - | go func() { | |
| 71 | - | if rh.validateSource != nil { | |
| 72 | - | if err := rh.validateSource(rh.ctx); err != nil { | |
| 73 | - | log.Warn(). | |
| 74 | - | Err(err). | |
| 75 | - | Str("source_url", sourceURL). | |
| 76 | - | Msg("cef: rejected transcoding source") | |
| 77 | - | rh.setStartErr(err, httpStatusForbidden) | |
| 78 | - | rh.cancel() | |
| 79 | - | callback.Cont() | |
| 80 | - | return | |
| 81 | - | } | |
| 82 | - | } | |
| 83 | - | ||
| 84 | - | session, err := rh.transcoder.Start(rh.ctx, rh.sourceURL, rh.headers) | |
| 85 | - | if err != nil { | |
| 86 | - | log.Error(). | |
| 87 | - | Err(err). | |
| 88 | - | Str("source_url", sourceURL). | |
| 89 | - | Msg("cef: failed to start transcoding resource stream") | |
| 90 | - | // Let CEF know we failed — Cancel will be called. | |
| 91 | - | rh.setStartErr(err, httpStatusBadGateway) | |
| 92 | - | rh.cancel() | |
| 93 | - | callback.Cont() | |
| 94 | - | return | |
| 95 | - | } | |
| 96 | - | rh.mu.Lock() | |
| 97 | - | rh.session = session | |
| 98 | - | rh.mu.Unlock() | |
| 99 | - | log.Info(). | |
| 100 | - | Str("source_url", sourceURL). | |
| 101 | - | Str("content_type", session.ContentType()). | |
| 102 | - | Msg("cef: transcoding resource stream ready") | |
| 103 | - | callback.Cont() | |
| 104 | - | }() | |
| 105 | - | ||
| 106 | - | return 1 | |
| 107 | - | } | |
| 108 | - | ||
| 109 | - | func (rh *transcodingResourceHandler) setStartErr(err error, status int) { | |
| 110 | - | rh.mu.Lock() | |
| 111 | - | defer rh.mu.Unlock() | |
| 112 | - | rh.startErr = err | |
| 113 | - | rh.startErrStatus = status | |
| 114 | - | } | |
| 115 | - | ||
| 116 | - | func (rh *transcodingResourceHandler) getStartErr() (int, error) { | |
| 117 | - | rh.mu.Lock() | |
| 118 | - | defer rh.mu.Unlock() | |
| 119 | - | return rh.startErrStatus, rh.startErr | |
| 120 | - | } | |
| 121 | - | ||
| 122 | - | func (rh *transcodingResourceHandler) addTotalBytes(n int) int64 { | |
| 123 | - | rh.mu.Lock() | |
| 124 | - | defer rh.mu.Unlock() | |
| 125 | - | rh.totalBytes += int64(n) | |
| 126 | - | return rh.totalBytes | |
| 127 | - | } | |
| 128 | - | ||
| 129 | - | func (rh *transcodingResourceHandler) getTotalBytes() int64 { | |
| 130 | - | rh.mu.Lock() | |
| 131 | - | defer rh.mu.Unlock() | |
| 132 | - | return rh.totalBytes | |
| 133 | - | } | |
| 134 | - | ||
| 135 | - | // GetResponseHeaders sets the streaming response metadata. | |
| 136 | - | func (rh *transcodingResourceHandler) GetResponseHeaders( | |
| 137 | - | response purecef.Response, responseLength *int64, _ uintptr, | |
| 138 | - | ) { | |
| 139 | - | if status, startErr := rh.getStartErr(); startErr != nil { | |
| 140 | - | if status == 0 { | |
| 141 | - | status = httpStatusBadGateway | |
| 142 | - | } | |
| 143 | - | response.SetStatus(int32(status)) | |
| 144 | - | if status == httpStatusForbidden { | |
| 145 | - | response.SetStatusText("Forbidden") | |
| 146 | - | } else { | |
| 147 | - | response.SetStatusText("Bad Gateway") | |
| 148 | - | } | |
| 149 | - | response.SetMimeType("text/plain") | |
| 150 | - | if responseLength != nil { | |
| 151 | - | *responseLength = 0 | |
| 152 | - | } | |
| 153 | - | return | |
| 154 | - | } | |
| 155 | - | response.SetStatus(httpStatusOK) | |
| 156 | - | response.SetStatusText("OK") | |
| 157 | - | response.SetMimeType("video/webm") | |
| 158 | - | response.SetHeaderByName("Accept-Ranges", "none", 1) | |
| 159 | - | // CORS wildcard is required because transcoded streams are loaded by <video> | |
| 160 | - | // elements on arbitrary origins (e.g. reddit.com). The streams contain no | |
| 161 | - | // sensitive data — they are re-encoded from publicly-accessible media URLs. | |
| 162 | - | response.SetHeaderByName("Access-Control-Allow-Origin", "*", 1) | |
| 163 | - | response.SetHeaderByName("Cache-Control", "no-store", 1) | |
| 164 | - | // Streaming — unknown length. | |
| 165 | - | if responseLength != nil { | |
| 166 | - | *responseLength = -1 | |
| 167 | - | } | |
| 168 | - | } | |
| 169 | - | ||
| 170 | - | // Read copies transcoded data from the session into the CEF output buffer. | |
| 171 | - | func (rh *transcodingResourceHandler) Read( | |
| 172 | - | dataOut unsafe.Pointer, bytesToRead int32, | |
| 173 | - | bytesRead *int32, _ purecef.ResourceReadCallback, | |
| 174 | - | ) int32 { | |
| 175 | - | rh.mu.Lock() | |
| 176 | - | session := rh.session | |
| 177 | - | rh.mu.Unlock() | |
| 178 | - | if session == nil { | |
| 179 | - | return 0 | |
| 180 | - | } | |
| 181 | - | ||
| 182 | - | dst := unsafe.Slice((*byte)(dataOut), int(bytesToRead)) | |
| 183 | - | n, err := session.Read(dst) | |
| 184 | - | if n > 0 { | |
| 185 | - | totalBytes := rh.addTotalBytes(n) | |
| 186 | - | if totalBytes == int64(n) { | |
| 187 | - | rh.logger().Info(). | |
| 188 | - | Str("source_url", logging.TruncateURL(rh.sourceURL, maxTranscodingURLLength)). | |
| 189 | - | Int("first_chunk_bytes", n). | |
| 190 | - | Msg("cef: transcoding resource stream produced first bytes") | |
| 191 | - | } | |
| 192 | - | if bytesRead != nil { | |
| 193 | - | *bytesRead = int32(n) | |
| 194 | - | } | |
| 195 | - | return 1 | |
| 196 | - | } | |
| 197 | - | if err != nil { | |
| 198 | - | totalBytes := rh.getTotalBytes() | |
| 199 | - | sourceURL := logging.TruncateURL(rh.sourceURL, maxTranscodingURLLength) | |
| 200 | - | log := rh.logger().With(). | |
| 201 | - | Str("source_url", sourceURL). | |
| 202 | - | Int64("total_bytes", totalBytes). | |
| 203 | - | Logger() | |
| 204 | - | switch { | |
| 205 | - | case errors.Is(err, io.EOF) && totalBytes == 0: | |
| 206 | - | log.Warn().Msg("cef: transcoding resource stream ended before producing data") | |
| 207 | - | case errors.Is(err, io.EOF): | |
| 208 | - | log.Debug().Msg("cef: transcoding resource stream reached EOF") | |
| 209 | - | default: | |
| 210 | - | log.Error().Err(err).Msg("cef: transcoding resource stream read failed") | |
| 211 | - | } | |
| 212 | - | return 0 // EOF or error | |
| 213 | - | } | |
| 214 | - | return 0 | |
| 215 | - | } | |
| 216 | - | ||
| 217 | - | // Cancel aborts the transcode session and releases resources. | |
| 218 | - | func (rh *transcodingResourceHandler) Cancel() { | |
| 219 | - | sourceURL := logging.TruncateURL(rh.sourceURL, maxTranscodingURLLength) | |
| 220 | - | totalBytes := rh.getTotalBytes() | |
| 221 | - | rh.logger().Debug(). | |
| 222 | - | Str("source_url", sourceURL). | |
| 223 | - | Int64("total_bytes", totalBytes). | |
| 224 | - | Msg("cef: transcoding resource stream canceled") | |
| 225 | - | rh.mu.Lock() | |
| 226 | - | session := rh.session | |
| 227 | - | rh.mu.Unlock() | |
| 228 | - | if session != nil { | |
| 229 | - | if err := session.Close(); err != nil { | |
| 230 | - | rh.logger().Warn(). | |
| 231 | - | Str("source_url", sourceURL). | |
| 232 | - | Err(err). | |
| 233 | - | Msg("cef: transcoding resource stream close failed") | |
| 234 | - | } | |
| 235 | - | } | |
| 236 | - | if rh.cancel != nil { | |
| 237 | - | rh.cancel() | |
| 238 | - | } | |
| 239 | - | } | |
| 240 | - | ||
| 241 | - | // ProcessRequest is deprecated; Open is used instead. | |
| 242 | - | func (rh *transcodingResourceHandler) ProcessRequest(_ purecef.Request, _ purecef.Callback) int32 { | |
| 243 | - | return 0 | |
| 244 | - | } | |
| 245 | - | ||
| 246 | - | // ReadResponse is deprecated; Read is used instead. | |
| 247 | - | func (rh *transcodingResourceHandler) ReadResponse(_ unsafe.Pointer, _ int32, _ *int32, _ purecef.Callback) int32 { | |
| 248 | - | return 0 | |
| 249 | - | } | |
| 250 | - | ||
| 251 | - | // Skip is not used for streaming content. | |
| 252 | - | func (rh *transcodingResourceHandler) Skip(_ int64, _ *int64, _ purecef.ResourceSkipCallback) int32 { | |
| 253 | - | return 0 | |
| 254 | - | } | |
| 255 | - | ||
| 256 | - | // --------------------------------------------------------------------------- | |
| 257 | - | // transcodingRequestHandler — ResourceRequestHandler that intercepts | |
| 258 | - | // proprietary video responses and streaming manifests, then restarts them | |
| 259 | - | // through the transcoder | |
| 260 | - | // --------------------------------------------------------------------------- | |
| 261 | - | ||
| 262 | - | type transcodingRequestHandler struct { | |
| 263 | - | transcoder port.MediaTranscoder | |
| 264 | - | classifier MediaClassifier | |
| 265 | - | transcodableURLs sync.Map // url string -> requestInfo | |
| 266 | - | ctxf func() context.Context | |
| 267 | - | } | |
| 268 | - | ||
| 269 | - | type requestInfo struct { | |
| 270 | - | headers map[string]string | |
| 271 | - | } | |
| 272 | - | ||
| 273 | - | // newTranscodingRequestHandler creates a ResourceRequestHandler that detects | |
| 274 | - | // proprietary video containers and HLS/DASH manifest entrypoints, then | |
| 275 | - | // restarts those requests through a GPU transcoding ResourceHandler. | |
| 276 | - | func newTranscodingRequestHandler( | |
| 277 | - | tc port.MediaTranscoder, classifier MediaClassifier, ctxf func() context.Context, | |
| 278 | - | ) purecef.ResourceRequestHandler { | |
| 279 | - | return purecef.NewResourceRequestHandler(&transcodingRequestHandler{ | |
| 280 | - | transcoder: tc, | |
| 281 | - | classifier: classifier, | |
| 282 | - | ctxf: ctxf, | |
| 283 | - | }) | |
| 284 | - | } | |
| 285 | - | ||
| 286 | - | // OnResourceResponse inspects the MIME type and URL of every response. If it | |
| 287 | - | // matches a proprietary video format or a streaming manifest entrypoint, it | |
| 288 | - | // stores the request info and returns 1 to restart the request — at which | |
| 289 | - | // point GetResourceHandler will provide the transcoding handler. | |
| 290 | - | func (h *transcodingRequestHandler) OnResourceResponse( | |
| 291 | - | _ purecef.Browser, _ purecef.Frame, request purecef.Request, response purecef.Response, | |
| 292 | - | ) int32 { | |
| 293 | - | if response == nil || request == nil { | |
| 294 | - | return 0 | |
| 295 | - | } | |
| 296 | - | mimeType := response.GetMimeType() | |
| 297 | - | url := request.GetURL() | |
| 298 | - | ||
| 299 | - | log := h.logger() | |
| 300 | - | isProprietary, isAlreadyOpen, isManifest := classifyMediaResponse(h.classifier, url, mimeType) | |
| 301 | - | if isProprietary || isAlreadyOpen || isManifest { | |
| 302 | - | log.Debug(). | |
| 303 | - | Str("url", logging.TruncateURL(url, maxTranscodingURLLength)). | |
| 304 | - | Str("mime_type", mimeType). | |
| 305 | - | Bool("proprietary_match", isProprietary). | |
| 306 | - | Bool("already_open", isAlreadyOpen). | |
| 307 | - | Bool("manifest_match", isManifest). | |
| 308 | - | Msg("cef: OnResourceResponse observed media response") | |
| 309 | - | } | |
| 310 | - | ||
| 311 | - | if isAlreadyOpen { | |
| 312 | - | return 0 | |
| 313 | - | } | |
| 314 | - | ||
| 315 | - | if !isProprietary && !isManifest { | |
| 316 | - | return 0 | |
| 317 | - | } | |
| 318 | - | ||
| 319 | - | info := buildRequestInfo(request) | |
| 320 | - | h.transcodableURLs.Store(url, info) | |
| 321 | - | reason := "proprietary-container" | |
| 322 | - | if isManifest { | |
| 323 | - | reason = "streaming-manifest" | |
| 324 | - | } | |
| 325 | - | log.Info(). | |
| 326 | - | Str("url", logging.TruncateURL(url, maxTranscodingURLLength)). | |
| 327 | - | Str("mime_type", mimeType). | |
| 328 | - | Str("transcode_reason", reason). | |
| 329 | - | Int("forwarded_header_count", len(info.headers)). | |
| 330 | - | Msg("cef: media response marked for transcoding") | |
| 331 | - | ||
| 332 | - | return 1 // restart request | |
| 333 | - | } | |
| 334 | - | ||
| 335 | - | // GetResourceHandler returns a transcoding ResourceHandler for URLs that were | |
| 336 | - | // either identified eagerly by URL heuristics or marked by OnResourceResponse. | |
| 337 | - | func (h *transcodingRequestHandler) GetResourceHandler( | |
| 338 | - | _ purecef.Browser, _ purecef.Frame, request purecef.Request, | |
| 339 | - | ) purecef.ResourceHandler { | |
| 340 | - | if request == nil { | |
| 341 | - | return nil | |
| 342 | - | } | |
| 343 | - | url := request.GetURL() | |
| 344 | - | val, ok := h.transcodableURLs.LoadAndDelete(url) | |
| 345 | - | info := requestInfo{} | |
| 346 | - | eager := false | |
| 347 | - | sourceURL := url | |
| 348 | - | if ok { | |
| 349 | - | cached, typeOK := val.(requestInfo) | |
| 350 | - | if !typeOK { | |
| 351 | - | return nil | |
| 352 | - | } | |
| 353 | - | info = cached | |
| 354 | - | } else if syntheticSourceURL, referer, origin, synthetic := resolveTranscodeSource(h.classifier, url); synthetic { | |
| 355 | - | eager = true | |
| 356 | - | sourceURL = syntheticSourceURL | |
| 357 | - | info = buildRequestInfo(request) | |
| 358 | - | if referer != "" && info.headers["Referer"] == "" { | |
| 359 | - | info.headers["Referer"] = referer | |
| 360 | - | } | |
| 361 | - | if origin != "" && info.headers["Origin"] == "" { | |
| 362 | - | info.headers["Origin"] = origin | |
| 363 | - | } | |
| 364 | - | } else { | |
| 365 | - | return nil | |
| 366 | - | } | |
| 367 | - | ||
| 368 | - | parentCtx := context.Background() | |
| 369 | - | if h.ctxf != nil { | |
| 370 | - | if provided := h.ctxf(); provided != nil { | |
| 371 | - | parentCtx = provided | |
| 372 | - | } | |
| 373 | - | } | |
| 374 | - | ctx, cancel := context.WithTimeout(parentCtx, 5*time.Minute) | |
| 375 | - | shortURL := logging.TruncateURL(url, maxTranscodingURLLength) | |
| 376 | - | shortSourceURL := logging.TruncateURL(sourceURL, maxTranscodingURLLength) | |
| 377 | - | h.logger().Info(). | |
| 378 | - | Str("url", shortURL). | |
| 379 | - | Str("source_url", shortSourceURL). | |
| 380 | - | Bool("eager_match", eager). | |
| 381 | - | Int("forwarded_header_count", len(info.headers)). | |
| 382 | - | Msg("cef: GetResourceHandler returning transcoding resource handler") | |
| 383 | - | return purecef.NewResourceHandler(&transcodingResourceHandler{ | |
| 384 | - | transcoder: h.transcoder, | |
| 385 | - | sourceURL: sourceURL, | |
| 386 | - | headers: info.headers, | |
| 387 | - | ctx: ctx, | |
| 388 | - | cancel: cancel, | |
| 389 | - | logf: h.logf, | |
| 390 | - | }) | |
| 391 | - | } | |
| 392 | - | ||
| 393 | - | func classifyMediaResponse(classifier MediaClassifier, rawURL, mimeType string) (bool, bool, bool) { | |
| 394 | - | proprietary := classifier.IsProprietaryVideoMIME(mimeType) | |
| 395 | - | alreadyOpen := classifier.IsOpenVideoMIME(mimeType) | |
| 396 | - | manifest := !alreadyOpen && (classifier.IsStreamingManifestMIME(mimeType) || classifier.IsStreamingManifestURL(rawURL)) | |
| 397 | - | return proprietary, alreadyOpen, manifest | |
| 398 | - | } | |
| 399 | - | ||
| 400 | - | func resolveTranscodeSource(classifier MediaClassifier, rawURL string) (string, string, string, bool) { | |
| 401 | - | if sourceURL, referer, origin, ok := classifier.ParseSyntheticTranscodeURL(rawURL); ok { | |
| 402 | - | return sourceURL, referer, origin, true | |
| 403 | - | } | |
| 404 | - | if classifier.IsEagerTranscodeURL(rawURL) { | |
| 405 | - | return rawURL, "", "", true | |
| 406 | - | } | |
| 407 | - | return "", "", "", false | |
| 408 | - | } | |
| 409 | - | ||
| 410 | - | // --- No-op methods required by purecef.ResourceRequestHandler --- | |
| 411 | - | ||
| 412 | - | func (h *transcodingRequestHandler) GetCookieAccessFilter( | |
| 413 | - | _ purecef.Browser, _ purecef.Frame, _ purecef.Request, | |
| 414 | - | ) purecef.CookieAccessFilter { | |
| 415 | - | return nil | |
| 416 | - | } | |
| 417 | - | ||
| 418 | - | func (h *transcodingRequestHandler) OnBeforeResourceLoad( | |
| 419 | - | _ purecef.Browser, _ purecef.Frame, _ purecef.Request, _ purecef.Callback, | |
| 420 | - | ) purecef.ReturnValue { | |
| 421 | - | return purecef.ReturnValueRvContinue | |
| 422 | - | } | |
| 423 | - | ||
| 424 | - | func (h *transcodingRequestHandler) OnResourceRedirect( | |
| 425 | - | _ purecef.Browser, _ purecef.Frame, _ purecef.Request, _ purecef.Response, _ uintptr, | |
| 426 | - | ) { | |
| 427 | - | } | |
| 428 | - | ||
| 429 | - | func (h *transcodingRequestHandler) GetResourceResponseFilter( | |
| 430 | - | _ purecef.Browser, _ purecef.Frame, _ purecef.Request, _ purecef.Response, | |
| 431 | - | ) purecef.ResponseFilter { | |
| 432 | - | return nil | |
| 433 | - | } | |
| 434 | - | ||
| 435 | - | func (h *transcodingRequestHandler) OnResourceLoadComplete( | |
| 436 | - | _ purecef.Browser, _ purecef.Frame, _ purecef.Request, _ purecef.Response, | |
| 437 | - | _ purecef.UrlrequestStatus, _ int64, | |
| 438 | - | ) { | |
| 439 | - | } | |
| 440 | - | ||
| 441 | - | func (h *transcodingRequestHandler) OnProtocolExecution(_ purecef.Browser, _ purecef.Frame, _ purecef.Request, _ *int32) { | |
| 442 | - | } | |
| 443 | - | ||
| 444 | - | func buildRequestInfo(request purecef.Request) requestInfo { | |
| 445 | - | headers := make(map[string]string) | |
| 446 | - | if request == nil { | |
| 447 | - | return requestInfo{headers: headers} | |
| 448 | - | } | |
| 449 | - | if cookie := request.GetHeaderByName("Cookie"); cookie != "" { | |
| 450 | - | headers["Cookie"] = cookie | |
| 451 | - | } | |
| 452 | - | if auth := request.GetHeaderByName("Authorization"); auth != "" { | |
| 453 | - | headers["Authorization"] = auth | |
| 454 | - | } | |
| 455 | - | if referer := request.GetHeaderByName("Referer"); referer != "" { | |
| 456 | - | headers["Referer"] = referer | |
| 457 | - | } | |
| 458 | - | if userAgent := request.GetHeaderByName("User-Agent"); userAgent != "" { | |
| 459 | - | headers["User-Agent"] = userAgent | |
| 460 | - | } | |
| 461 | - | if origin := request.GetHeaderByName("Origin"); origin != "" { | |
| 462 | - | headers["Origin"] = origin | |
| 463 | - | } | |
| 464 | - | return requestInfo{headers: headers} | |
| 465 | - | } | |
| 466 | - | ||
| 467 | - | func (h *transcodingRequestHandler) logger() *zerolog.Logger { | |
| 468 | - | logger := h.logf() | |
| 469 | - | return &logger | |
| 470 | - | } | |
| 471 | - | ||
| 472 | - | func (h *transcodingRequestHandler) logf() zerolog.Logger { | |
| 473 | - | ctx := context.Background() | |
| 474 | - | if h != nil && h.ctxf != nil { | |
| 475 | - | if provided := h.ctxf(); provided != nil { | |
| 476 | - | ctx = provided | |
| 477 | - | } | |
| 478 | - | } | |
| 479 | - | return logging.FromContext(ctx).With().Str("component", "cef-transcoding").Logger() | |
| 480 | - | } | |
| 481 | - | ||
| 482 | - | func (rh *transcodingResourceHandler) logger() *zerolog.Logger { | |
| 483 | - | if rh != nil && rh.logf != nil { | |
| 484 | - | sourceURL := logging.TruncateURL(rh.sourceURL, maxTranscodingURLLength) | |
| 485 | - | logger := rh.logf().With().Str("source_url", sourceURL).Logger() | |
| 486 | - | return &logger | |
| 487 | - | } | |
| 488 | - | logger := logging.FromContext(context.Background()).With().Str("component", "cef-transcoding").Logger() | |
| 489 | - | return &logger | |
| 490 | - | } | |
| @@ -1,490 +0,0 @@ | |||||
| 1 | - | package cef | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "context" | |||
| 5 | - | "errors" | |||
| 6 | - | "io" | |||
| 7 | - | "sync" | |||
| 8 | - | "time" | |||
| 9 | - | "unsafe" | |||
| 10 | - | ||||
| 11 | - | purecef "github.com/bnema/purego-cef/cef" | |||
| 12 | - | ||||
| 13 | - | "github.com/bnema/dumber/internal/application/port" | |||
| 14 | - | "github.com/bnema/dumber/internal/logging" | |||
| 15 | - | "github.com/rs/zerolog" | |||
| 16 | - | ) | |||
| 17 | - | ||||
| 18 | - | // --------------------------------------------------------------------------- | |||
| 19 | - | // Compile-time interface checks | |||
| 20 | - | // --------------------------------------------------------------------------- | |||
| 21 | - | ||||
| 22 | - | var _ purecef.ResourceRequestHandler = (*transcodingRequestHandler)(nil) | |||
| 23 | - | ||||
| 24 | - | const ( | |||
| 25 | - | maxTranscodingURLLength = 240 | |||
| 26 | - | httpStatusOK = 200 | |||
| 27 | - | httpStatusForbidden = 403 | |||
| 28 | - | httpStatusBadGateway = 502 | |||
| 29 | - | ) | |||
| 30 | - | ||||
| 31 | - | // transcodingResourceHandler is checked indirectly: purecef.NewResourceHandler | |||
| 32 | - | // accepts a purecef.ResourceHandler, and the struct implements all methods. | |||
| 33 | - | ||||
| 34 | - | // --------------------------------------------------------------------------- | |||
| 35 | - | // transcodingResourceHandler — serves a transcoded WebM stream to CEF | |||
| 36 | - | // --------------------------------------------------------------------------- | |||
| 37 | - | ||||
| 38 | - | type transcodingResourceHandler struct { | |||
| 39 | - | transcoder port.MediaTranscoder | |||
| 40 | - | sourceURL string | |||
| 41 | - | headers map[string]string | |||
| 42 | - | session port.TranscodeSession | |||
| 43 | - | ctx context.Context | |||
| 44 | - | cancel context.CancelFunc | |||
| 45 | - | logf func() zerolog.Logger | |||
| 46 | - | validateSource func(context.Context) error | |||
| 47 | - | mu sync.Mutex | |||
| 48 | - | startErr error | |||
| 49 | - | startErrStatus int | |||
| 50 | - | totalBytes int64 | |||
| 51 | - | } | |||
| 52 | - | ||||
| 53 | - | // Open starts the transcode session asynchronously. CEF will call | |||
| 54 | - | // GetResponseHeaders and Read once the callback fires. | |||
| 55 | - | func (rh *transcodingResourceHandler) Open( | |||
| 56 | - | _ purecef.Request, handleRequest *int32, callback purecef.Callback, | |||
| 57 | - | ) int32 { | |||
| 58 | - | // Signal async handling: set handleRequest = 0. | |||
| 59 | - | if handleRequest != nil { | |||
| 60 | - | *handleRequest = 0 | |||
| 61 | - | } | |||
| 62 | - | ||||
| 63 | - | log := rh.logger() | |||
| 64 | - | sourceURL := logging.TruncateURL(rh.sourceURL, maxTranscodingURLLength) | |||
| 65 | - | log.Info(). | |||
| 66 | - | Str("source_url", sourceURL). | |||
| 67 | - | Int("forwarded_header_count", len(rh.headers)). | |||
| 68 | - | Msg("cef: starting transcoding resource stream") | |||
| 69 | - | ||||
| 70 | - | go func() { | |||
| 71 | - | if rh.validateSource != nil { | |||
| 72 | - | if err := rh.validateSource(rh.ctx); err != nil { | |||
| 73 | - | log.Warn(). | |||
| 74 | - | Err(err). | |||
| 75 | - | Str("source_url", sourceURL). | |||
| 76 | - | Msg("cef: rejected transcoding source") | |||
| 77 | - | rh.setStartErr(err, httpStatusForbidden) | |||
| 78 | - | rh.cancel() | |||
| 79 | - | callback.Cont() | |||
| 80 | - | return | |||
| 81 | - | } | |||
| 82 | - | } | |||
| 83 | - | ||||
| 84 | - | session, err := rh.transcoder.Start(rh.ctx, rh.sourceURL, rh.headers) | |||
| 85 | - | if err != nil { | |||
| 86 | - | log.Error(). | |||
| 87 | - | Err(err). | |||
| 88 | - | Str("source_url", sourceURL). | |||
| 89 | - | Msg("cef: failed to start transcoding resource stream") | |||
| 90 | - | // Let CEF know we failed — Cancel will be called. | |||
| 91 | - | rh.setStartErr(err, httpStatusBadGateway) | |||
| 92 | - | rh.cancel() | |||
| 93 | - | callback.Cont() | |||
| 94 | - | return | |||
| 95 | - | } | |||
| 96 | - | rh.mu.Lock() | |||
| 97 | - | rh.session = session | |||
| 98 | - | rh.mu.Unlock() | |||
| 99 | - | log.Info(). | |||
| 100 | - | Str("source_url", sourceURL). | |||
| 101 | - | Str("content_type", session.ContentType()). | |||
| 102 | - | Msg("cef: transcoding resource stream ready") | |||
| 103 | - | callback.Cont() | |||
| 104 | - | }() | |||
| 105 | - | ||||
| 106 | - | return 1 | |||
| 107 | - | } | |||
| 108 | - | ||||
| 109 | - | func (rh *transcodingResourceHandler) setStartErr(err error, status int) { | |||
| 110 | - | rh.mu.Lock() | |||
| 111 | - | defer rh.mu.Unlock() | |||
| 112 | - | rh.startErr = err | |||
| 113 | - | rh.startErrStatus = status | |||
| 114 | - | } | |||
| 115 | - | ||||
| 116 | - | func (rh *transcodingResourceHandler) getStartErr() (int, error) { | |||
| 117 | - | rh.mu.Lock() | |||
| 118 | - | defer rh.mu.Unlock() | |||
| 119 | - | return rh.startErrStatus, rh.startErr | |||
| 120 | - | } | |||
| 121 | - | ||||
| 122 | - | func (rh *transcodingResourceHandler) addTotalBytes(n int) int64 { | |||
| 123 | - | rh.mu.Lock() | |||
| 124 | - | defer rh.mu.Unlock() | |||
| 125 | - | rh.totalBytes += int64(n) | |||
| 126 | - | return rh.totalBytes | |||
| 127 | - | } | |||
| 128 | - | ||||
| 129 | - | func (rh *transcodingResourceHandler) getTotalBytes() int64 { | |||
| 130 | - | rh.mu.Lock() | |||
| 131 | - | defer rh.mu.Unlock() | |||
| 132 | - | return rh.totalBytes | |||
| 133 | - | } | |||
| 134 | - | ||||
| 135 | - | // GetResponseHeaders sets the streaming response metadata. | |||
| 136 | - | func (rh *transcodingResourceHandler) GetResponseHeaders( | |||
| 137 | - | response purecef.Response, responseLength *int64, _ uintptr, | |||
| 138 | - | ) { | |||
| 139 | - | if status, startErr := rh.getStartErr(); startErr != nil { | |||
| 140 | - | if status == 0 { | |||
| 141 | - | status = httpStatusBadGateway | |||
| 142 | - | } | |||
| 143 | - | response.SetStatus(int32(status)) | |||
| 144 | - | if status == httpStatusForbidden { | |||
| 145 | - | response.SetStatusText("Forbidden") | |||
| 146 | - | } else { | |||
| 147 | - | response.SetStatusText("Bad Gateway") | |||
| 148 | - | } | |||
| 149 | - | response.SetMimeType("text/plain") | |||
| 150 | - | if responseLength != nil { | |||
| 151 | - | *responseLength = 0 | |||
| 152 | - | } | |||
| 153 | - | return | |||
| 154 | - | } | |||
| 155 | - | response.SetStatus(httpStatusOK) | |||
| 156 | - | response.SetStatusText("OK") | |||
| 157 | - | response.SetMimeType("video/webm") | |||
| 158 | - | response.SetHeaderByName("Accept-Ranges", "none", 1) | |||
| 159 | - | // CORS wildcard is required because transcoded streams are loaded by <video> | |||
| 160 | - | // elements on arbitrary origins (e.g. reddit.com). The streams contain no | |||
| 161 | - | // sensitive data — they are re-encoded from publicly-accessible media URLs. | |||
| 162 | - | response.SetHeaderByName("Access-Control-Allow-Origin", "*", 1) | |||
| 163 | - | response.SetHeaderByName("Cache-Control", "no-store", 1) | |||
| 164 | - | // Streaming — unknown length. | |||
| 165 | - | if responseLength != nil { | |||
| 166 | - | *responseLength = -1 | |||
| 167 | - | } | |||
| 168 | - | } | |||
| 169 | - | ||||
| 170 | - | // Read copies transcoded data from the session into the CEF output buffer. | |||
| 171 | - | func (rh *transcodingResourceHandler) Read( | |||
| 172 | - | dataOut unsafe.Pointer, bytesToRead int32, | |||
| 173 | - | bytesRead *int32, _ purecef.ResourceReadCallback, | |||
| 174 | - | ) int32 { | |||
| 175 | - | rh.mu.Lock() | |||
| 176 | - | session := rh.session | |||
| 177 | - | rh.mu.Unlock() | |||
| 178 | - | if session == nil { | |||
| 179 | - | return 0 | |||
| 180 | - | } | |||
| 181 | - | ||||
| 182 | - | dst := unsafe.Slice((*byte)(dataOut), int(bytesToRead)) | |||
| 183 | - | n, err := session.Read(dst) | |||
| 184 | - | if n > 0 { | |||
| 185 | - | totalBytes := rh.addTotalBytes(n) | |||
| 186 | - | if totalBytes == int64(n) { | |||
| 187 | - | rh.logger().Info(). | |||
| 188 | - | Str("source_url", logging.TruncateURL(rh.sourceURL, maxTranscodingURLLength)). | |||
| 189 | - | Int("first_chunk_bytes", n). | |||
| 190 | - | Msg("cef: transcoding resource stream produced first bytes") | |||
| 191 | - | } | |||
| 192 | - | if bytesRead != nil { | |||
| 193 | - | *bytesRead = int32(n) | |||
| 194 | - | } | |||
| 195 | - | return 1 | |||
| 196 | - | } | |||
| 197 | - | if err != nil { | |||
| 198 | - | totalBytes := rh.getTotalBytes() | |||
| 199 | - | sourceURL := logging.TruncateURL(rh.sourceURL, maxTranscodingURLLength) | |||
| 200 | - | log := rh.logger().With(). | |||
| 201 | - | Str("source_url", sourceURL). | |||
| 202 | - | Int64("total_bytes", totalBytes). | |||
| 203 | - | Logger() | |||
| 204 | - | switch { | |||
| 205 | - | case errors.Is(err, io.EOF) && totalBytes == 0: | |||
| 206 | - | log.Warn().Msg("cef: transcoding resource stream ended before producing data") | |||
| 207 | - | case errors.Is(err, io.EOF): | |||
| 208 | - | log.Debug().Msg("cef: transcoding resource stream reached EOF") | |||
| 209 | - | default: | |||
| 210 | - | log.Error().Err(err).Msg("cef: transcoding resource stream read failed") | |||
| 211 | - | } | |||
| 212 | - | return 0 // EOF or error | |||
| 213 | - | } | |||
| 214 | - | return 0 | |||
| 215 | - | } | |||
| 216 | - | ||||
| 217 | - | // Cancel aborts the transcode session and releases resources. | |||
| 218 | - | func (rh *transcodingResourceHandler) Cancel() { | |||
| 219 | - | sourceURL := logging.TruncateURL(rh.sourceURL, maxTranscodingURLLength) | |||
| 220 | - | totalBytes := rh.getTotalBytes() | |||
| 221 | - | rh.logger().Debug(). | |||
| 222 | - | Str("source_url", sourceURL). | |||
| 223 | - | Int64("total_bytes", totalBytes). | |||
| 224 | - | Msg("cef: transcoding resource stream canceled") | |||
| 225 | - | rh.mu.Lock() | |||
| 226 | - | session := rh.session | |||
| 227 | - | rh.mu.Unlock() | |||
| 228 | - | if session != nil { | |||
| 229 | - | if err := session.Close(); err != nil { | |||
| 230 | - | rh.logger().Warn(). | |||
| 231 | - | Str("source_url", sourceURL). | |||
| 232 | - | Err(err). | |||
| 233 | - | Msg("cef: transcoding resource stream close failed") | |||
| 234 | - | } | |||
| 235 | - | } | |||
| 236 | - | if rh.cancel != nil { | |||
| 237 | - | rh.cancel() | |||
| 238 | - | } | |||
| 239 | - | } | |||
| 240 | - | ||||
| 241 | - | // ProcessRequest is deprecated; Open is used instead. | |||
| 242 | - | func (rh *transcodingResourceHandler) ProcessRequest(_ purecef.Request, _ purecef.Callback) int32 { | |||
| 243 | - | return 0 | |||
| 244 | - | } | |||
| 245 | - | ||||
| 246 | - | // ReadResponse is deprecated; Read is used instead. | |||
| 247 | - | func (rh *transcodingResourceHandler) ReadResponse(_ unsafe.Pointer, _ int32, _ *int32, _ purecef.Callback) int32 { | |||
| 248 | - | return 0 | |||
| 249 | - | } | |||
| 250 | - | ||||
| 251 | - | // Skip is not used for streaming content. | |||
| 252 | - | func (rh *transcodingResourceHandler) Skip(_ int64, _ *int64, _ purecef.ResourceSkipCallback) int32 { | |||
| 253 | - | return 0 | |||
| 254 | - | } | |||
| 255 | - | ||||
| 256 | - | // --------------------------------------------------------------------------- | |||
| 257 | - | // transcodingRequestHandler — ResourceRequestHandler that intercepts | |||
| 258 | - | // proprietary video responses and streaming manifests, then restarts them | |||
| 259 | - | // through the transcoder | |||
| 260 | - | // --------------------------------------------------------------------------- | |||
| 261 | - | ||||
| 262 | - | type transcodingRequestHandler struct { | |||
| 263 | - | transcoder port.MediaTranscoder | |||
| 264 | - | classifier MediaClassifier | |||
| 265 | - | transcodableURLs sync.Map // url string -> requestInfo | |||
| 266 | - | ctxf func() context.Context | |||
| 267 | - | } | |||
| 268 | - | ||||
| 269 | - | type requestInfo struct { | |||
| 270 | - | headers map[string]string | |||
| 271 | - | } | |||
| 272 | - | ||||
| 273 | - | // newTranscodingRequestHandler creates a ResourceRequestHandler that detects | |||
| 274 | - | // proprietary video containers and HLS/DASH manifest entrypoints, then | |||
| 275 | - | // restarts those requests through a GPU transcoding ResourceHandler. | |||
| 276 | - | func newTranscodingRequestHandler( | |||
| 277 | - | tc port.MediaTranscoder, classifier MediaClassifier, ctxf func() context.Context, | |||
| 278 | - | ) purecef.ResourceRequestHandler { | |||
| 279 | - | return purecef.NewResourceRequestHandler(&transcodingRequestHandler{ | |||
| 280 | - | transcoder: tc, | |||
| 281 | - | classifier: classifier, | |||
| 282 | - | ctxf: ctxf, | |||
| 283 | - | }) | |||
| 284 | - | } | |||
| 285 | - | ||||
| 286 | - | // OnResourceResponse inspects the MIME type and URL of every response. If it | |||
| 287 | - | // matches a proprietary video format or a streaming manifest entrypoint, it | |||
| 288 | - | // stores the request info and returns 1 to restart the request — at which | |||
| 289 | - | // point GetResourceHandler will provide the transcoding handler. | |||
| 290 | - | func (h *transcodingRequestHandler) OnResourceResponse( | |||
| 291 | - | _ purecef.Browser, _ purecef.Frame, request purecef.Request, response purecef.Response, | |||
| 292 | - | ) int32 { | |||
| 293 | - | if response == nil || request == nil { | |||
| 294 | - | return 0 | |||
| 295 | - | } | |||
| 296 | - | mimeType := response.GetMimeType() | |||
| 297 | - | url := request.GetURL() | |||
| 298 | - | ||||
| 299 | - | log := h.logger() | |||
| 300 | - | isProprietary, isAlreadyOpen, isManifest := classifyMediaResponse(h.classifier, url, mimeType) | |||
| 301 | - | if isProprietary || isAlreadyOpen || isManifest { | |||
| 302 | - | log.Debug(). | |||
| 303 | - | Str("url", logging.TruncateURL(url, maxTranscodingURLLength)). | |||
| 304 | - | Str("mime_type", mimeType). | |||
| 305 | - | Bool("proprietary_match", isProprietary). | |||
| 306 | - | Bool("already_open", isAlreadyOpen). | |||
| 307 | - | Bool("manifest_match", isManifest). | |||
| 308 | - | Msg("cef: OnResourceResponse observed media response") | |||
| 309 | - | } | |||
| 310 | - | ||||
| 311 | - | if isAlreadyOpen { | |||
| 312 | - | return 0 | |||
| 313 | - | } | |||
| 314 | - | ||||
| 315 | - | if !isProprietary && !isManifest { | |||
| 316 | - | return 0 | |||
| 317 | - | } | |||
| 318 | - | ||||
| 319 | - | info := buildRequestInfo(request) | |||
| 320 | - | h.transcodableURLs.Store(url, info) | |||
| 321 | - | reason := "proprietary-container" | |||
| 322 | - | if isManifest { | |||
| 323 | - | reason = "streaming-manifest" | |||
| 324 | - | } | |||
| 325 | - | log.Info(). | |||
| 326 | - | Str("url", logging.TruncateURL(url, maxTranscodingURLLength)). | |||
| 327 | - | Str("mime_type", mimeType). | |||
| 328 | - | Str("transcode_reason", reason). | |||
| 329 | - | Int("forwarded_header_count", len(info.headers)). | |||
| 330 | - | Msg("cef: media response marked for transcoding") | |||
| 331 | - | ||||
| 332 | - | return 1 // restart request | |||
| 333 | - | } | |||
| 334 | - | ||||
| 335 | - | // GetResourceHandler returns a transcoding ResourceHandler for URLs that were | |||
| 336 | - | // either identified eagerly by URL heuristics or marked by OnResourceResponse. | |||
| 337 | - | func (h *transcodingRequestHandler) GetResourceHandler( | |||
| 338 | - | _ purecef.Browser, _ purecef.Frame, request purecef.Request, | |||
| 339 | - | ) purecef.ResourceHandler { | |||
| 340 | - | if request == nil { | |||
| 341 | - | return nil | |||
| 342 | - | } | |||
| 343 | - | url := request.GetURL() | |||
| 344 | - | val, ok := h.transcodableURLs.LoadAndDelete(url) | |||
| 345 | - | info := requestInfo{} | |||
| 346 | - | eager := false | |||
| 347 | - | sourceURL := url | |||
| 348 | - | if ok { | |||
| 349 | - | cached, typeOK := val.(requestInfo) | |||
| 350 | - | if !typeOK { | |||
| 351 | - | return nil | |||
| 352 | - | } | |||
| 353 | - | info = cached | |||
| 354 | - | } else if syntheticSourceURL, referer, origin, synthetic := resolveTranscodeSource(h.classifier, url); synthetic { | |||
| 355 | - | eager = true | |||
| 356 | - | sourceURL = syntheticSourceURL | |||
| 357 | - | info = buildRequestInfo(request) | |||
| 358 | - | if referer != "" && info.headers["Referer"] == "" { | |||
| 359 | - | info.headers["Referer"] = referer | |||
| 360 | - | } | |||
| 361 | - | if origin != "" && info.headers["Origin"] == "" { | |||
| 362 | - | info.headers["Origin"] = origin | |||
| 363 | - | } | |||
| 364 | - | } else { | |||
| 365 | - | return nil | |||
| 366 | - | } | |||
| 367 | - | ||||
| 368 | - | parentCtx := context.Background() | |||
| 369 | - | if h.ctxf != nil { | |||
| 370 | - | if provided := h.ctxf(); provided != nil { | |||
| 371 | - | parentCtx = provided | |||
| 372 | - | } | |||
| 373 | - | } | |||
| 374 | - | ctx, cancel := context.WithTimeout(parentCtx, 5*time.Minute) | |||
| 375 | - | shortURL := logging.TruncateURL(url, maxTranscodingURLLength) | |||
| 376 | - | shortSourceURL := logging.TruncateURL(sourceURL, maxTranscodingURLLength) | |||
| 377 | - | h.logger().Info(). | |||
| 378 | - | Str("url", shortURL). | |||
| 379 | - | Str("source_url", shortSourceURL). | |||
| 380 | - | Bool("eager_match", eager). | |||
| 381 | - | Int("forwarded_header_count", len(info.headers)). | |||
| 382 | - | Msg("cef: GetResourceHandler returning transcoding resource handler") | |||
| 383 | - | return purecef.NewResourceHandler(&transcodingResourceHandler{ | |||
| 384 | - | transcoder: h.transcoder, | |||
| 385 | - | sourceURL: sourceURL, | |||
| 386 | - | headers: info.headers, | |||
| 387 | - | ctx: ctx, | |||
| 388 | - | cancel: cancel, | |||
| 389 | - | logf: h.logf, | |||
| 390 | - | }) | |||
| 391 | - | } | |||
| 392 | - | ||||
| 393 | - | func classifyMediaResponse(classifier MediaClassifier, rawURL, mimeType string) (bool, bool, bool) { | |||
| 394 | - | proprietary := classifier.IsProprietaryVideoMIME(mimeType) | |||
| 395 | - | alreadyOpen := classifier.IsOpenVideoMIME(mimeType) | |||
| 396 | - | manifest := !alreadyOpen && (classifier.IsStreamingManifestMIME(mimeType) || classifier.IsStreamingManifestURL(rawURL)) | |||
| 397 | - | return proprietary, alreadyOpen, manifest | |||
| 398 | - | } | |||
| 399 | - | ||||
| 400 | - | func resolveTranscodeSource(classifier MediaClassifier, rawURL string) (string, string, string, bool) { | |||
| 401 | - | if sourceURL, referer, origin, ok := classifier.ParseSyntheticTranscodeURL(rawURL); ok { | |||
| 402 | - | return sourceURL, referer, origin, true | |||
| 403 | - | } | |||
| 404 | - | if classifier.IsEagerTranscodeURL(rawURL) { | |||
| 405 | - | return rawURL, "", "", true | |||
| 406 | - | } | |||
| 407 | - | return "", "", "", false | |||
| 408 | - | } | |||
| 409 | - | ||||
| 410 | - | // --- No-op methods required by purecef.ResourceRequestHandler --- | |||
| 411 | - | ||||
| 412 | - | func (h *transcodingRequestHandler) GetCookieAccessFilter( | |||
| 413 | - | _ purecef.Browser, _ purecef.Frame, _ purecef.Request, | |||
| 414 | - | ) purecef.CookieAccessFilter { | |||
| 415 | - | return nil | |||
| 416 | - | } | |||
| 417 | - | ||||
| 418 | - | func (h *transcodingRequestHandler) OnBeforeResourceLoad( | |||
| 419 | - | _ purecef.Browser, _ purecef.Frame, _ purecef.Request, _ purecef.Callback, | |||
| 420 | - | ) purecef.ReturnValue { | |||
| 421 | - | return purecef.ReturnValueRvContinue | |||
| 422 | - | } | |||
| 423 | - | ||||
| 424 | - | func (h *transcodingRequestHandler) OnResourceRedirect( | |||
| 425 | - | _ purecef.Browser, _ purecef.Frame, _ purecef.Request, _ purecef.Response, _ uintptr, | |||
| 426 | - | ) { | |||
| 427 | - | } | |||
| 428 | - | ||||
| 429 | - | func (h *transcodingRequestHandler) GetResourceResponseFilter( | |||
| 430 | - | _ purecef.Browser, _ purecef.Frame, _ purecef.Request, _ purecef.Response, | |||
| 431 | - | ) purecef.ResponseFilter { | |||
| 432 | - | return nil | |||
| 433 | - | } | |||
| 434 | - | ||||
| 435 | - | func (h *transcodingRequestHandler) OnResourceLoadComplete( | |||
| 436 | - | _ purecef.Browser, _ purecef.Frame, _ purecef.Request, _ purecef.Response, | |||
| 437 | - | _ purecef.UrlrequestStatus, _ int64, | |||
| 438 | - | ) { | |||
| 439 | - | } | |||
| 440 | - | ||||
| 441 | - | func (h *transcodingRequestHandler) OnProtocolExecution(_ purecef.Browser, _ purecef.Frame, _ purecef.Request, _ *int32) { | |||
| 442 | - | } | |||
| 443 | - | ||||
| 444 | - | func buildRequestInfo(request purecef.Request) requestInfo { | |||
| 445 | - | headers := make(map[string]string) | |||
| 446 | - | if request == nil { | |||
| 447 | - | return requestInfo{headers: headers} | |||
| 448 | - | } | |||
| 449 | - | if cookie := request.GetHeaderByName("Cookie"); cookie != "" { | |||
| 450 | - | headers["Cookie"] = cookie | |||
| 451 | - | } | |||
| 452 | - | if auth := request.GetHeaderByName("Authorization"); auth != "" { | |||
| 453 | - | headers["Authorization"] = auth | |||
| 454 | - | } | |||
| 455 | - | if referer := request.GetHeaderByName("Referer"); referer != "" { | |||
| 456 | - | headers["Referer"] = referer | |||
| 457 | - | } | |||
| 458 | - | if userAgent := request.GetHeaderByName("User-Agent"); userAgent != "" { | |||
| 459 | - | headers["User-Agent"] = userAgent | |||
| 460 | - | } | |||
| 461 | - | if origin := request.GetHeaderByName("Origin"); origin != "" { | |||
| 462 | - | headers["Origin"] = origin | |||
| 463 | - | } | |||
| 464 | - | return requestInfo{headers: headers} | |||
| 465 | - | } | |||
| 466 | - | ||||
| 467 | - | func (h *transcodingRequestHandler) logger() *zerolog.Logger { | |||
| 468 | - | logger := h.logf() | |||
| 469 | - | return &logger | |||
| 470 | - | } | |||
| 471 | - | ||||
| 472 | - | func (h *transcodingRequestHandler) logf() zerolog.Logger { | |||
| 473 | - | ctx := context.Background() | |||
| 474 | - | if h != nil && h.ctxf != nil { | |||
| 475 | - | if provided := h.ctxf(); provided != nil { | |||
| 476 | - | ctx = provided | |||
| 477 | - | } | |||
| 478 | - | } | |||
| 479 | - | return logging.FromContext(ctx).With().Str("component", "cef-transcoding").Logger() | |||
| 480 | - | } | |||
| 481 | - | ||||
| 482 | - | func (rh *transcodingResourceHandler) logger() *zerolog.Logger { | |||
| 483 | - | if rh != nil && rh.logf != nil { | |||
| 484 | - | sourceURL := logging.TruncateURL(rh.sourceURL, maxTranscodingURLLength) | |||
| 485 | - | logger := rh.logf().With().Str("source_url", sourceURL).Logger() | |||
| 486 | - | return &logger | |||
| 487 | - | } | |||
| 488 | - | logger := logging.FromContext(context.Background()).With().Str("component", "cef-transcoding").Logger() | |||
| 489 | - | return &logger | |||
| 490 | - | } | |||
D internal/infrastructure/cef/transcoding_handler_test.go +0 -49
| @@ -1,49 +0,0 @@ | |||
| 1 | - | package cef | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "testing" | |
| 5 | - | ||
| 6 | - | "github.com/stretchr/testify/require" | |
| 7 | - | ) | |
| 8 | - | ||
| 9 | - | func TestClassifyMediaResponse_UsesInjectedClassifier(t *testing.T) { | |
| 10 | - | classifier := MediaClassifier{ | |
| 11 | - | IsProprietaryVideoMIME: func(string) bool { return true }, | |
| 12 | - | IsOpenVideoMIME: func(string) bool { return false }, | |
| 13 | - | IsStreamingManifestMIME: func(string) bool { return false }, | |
| 14 | - | IsStreamingManifestURL: func(string) bool { return false }, | |
| 15 | - | }.normalize() | |
| 16 | - | ||
| 17 | - | proprietary, alreadyOpen, manifest := classifyMediaResponse(classifier, "https://cdn.example/video.mp4", "video/mp4") | |
| 18 | - | require.True(t, proprietary) | |
| 19 | - | require.False(t, alreadyOpen) | |
| 20 | - | require.False(t, manifest) | |
| 21 | - | } | |
| 22 | - | ||
| 23 | - | func TestResolveTranscodeSource_UsesInjectedClassifier(t *testing.T) { | |
| 24 | - | classifier := MediaClassifier{ | |
| 25 | - | ParseSyntheticTranscodeURL: func(string) (string, string, string, bool) { | |
| 26 | - | return "https://cdn.example/video.mp4", "https://example.com", "https://example.com", true | |
| 27 | - | }, | |
| 28 | - | IsEagerTranscodeURL: func(string) bool { return false }, | |
| 29 | - | }.normalize() | |
| 30 | - | ||
| 31 | - | src, referer, origin, eager := resolveTranscodeSource(classifier, "https://dumber.invalid/__dumber__/transcode.webm?src=x") | |
| 32 | - | require.True(t, eager) | |
| 33 | - | require.Equal(t, "https://cdn.example/video.mp4", src) | |
| 34 | - | require.Equal(t, "https://example.com", referer) | |
| 35 | - | require.Equal(t, "https://example.com", origin) | |
| 36 | - | } | |
| 37 | - | ||
| 38 | - | func TestResolveTranscodeSource_UsesEagerFallback(t *testing.T) { | |
| 39 | - | classifier := MediaClassifier{ | |
| 40 | - | ParseSyntheticTranscodeURL: func(string) (string, string, string, bool) { return "", "", "", false }, | |
| 41 | - | IsEagerTranscodeURL: func(string) bool { return true }, | |
| 42 | - | }.normalize() | |
| 43 | - | ||
| 44 | - | src, referer, origin, eager := resolveTranscodeSource(classifier, "https://cdn.example/video.mp4") | |
| 45 | - | require.True(t, eager) | |
| 46 | - | require.Equal(t, "https://cdn.example/video.mp4", src) | |
| 47 | - | require.Empty(t, referer) | |
| 48 | - | require.Empty(t, origin) | |
| 49 | - | } | |
| @@ -1,49 +0,0 @@ | |||||
| 1 | - | package cef | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "testing" | |||
| 5 | - | ||||
| 6 | - | "github.com/stretchr/testify/require" | |||
| 7 | - | ) | |||
| 8 | - | ||||
| 9 | - | func TestClassifyMediaResponse_UsesInjectedClassifier(t *testing.T) { | |||
| 10 | - | classifier := MediaClassifier{ | |||
| 11 | - | IsProprietaryVideoMIME: func(string) bool { return true }, | |||
| 12 | - | IsOpenVideoMIME: func(string) bool { return false }, | |||
| 13 | - | IsStreamingManifestMIME: func(string) bool { return false }, | |||
| 14 | - | IsStreamingManifestURL: func(string) bool { return false }, | |||
| 15 | - | }.normalize() | |||
| 16 | - | ||||
| 17 | - | proprietary, alreadyOpen, manifest := classifyMediaResponse(classifier, "https://cdn.example/video.mp4", "video/mp4") | |||
| 18 | - | require.True(t, proprietary) | |||
| 19 | - | require.False(t, alreadyOpen) | |||
| 20 | - | require.False(t, manifest) | |||
| 21 | - | } | |||
| 22 | - | ||||
| 23 | - | func TestResolveTranscodeSource_UsesInjectedClassifier(t *testing.T) { | |||
| 24 | - | classifier := MediaClassifier{ | |||
| 25 | - | ParseSyntheticTranscodeURL: func(string) (string, string, string, bool) { | |||
| 26 | - | return "https://cdn.example/video.mp4", "https://example.com", "https://example.com", true | |||
| 27 | - | }, | |||
| 28 | - | IsEagerTranscodeURL: func(string) bool { return false }, | |||
| 29 | - | }.normalize() | |||
| 30 | - | ||||
| 31 | - | src, referer, origin, eager := resolveTranscodeSource(classifier, "https://dumber.invalid/__dumber__/transcode.webm?src=x") | |||
| 32 | - | require.True(t, eager) | |||
| 33 | - | require.Equal(t, "https://cdn.example/video.mp4", src) | |||
| 34 | - | require.Equal(t, "https://example.com", referer) | |||
| 35 | - | require.Equal(t, "https://example.com", origin) | |||
| 36 | - | } | |||
| 37 | - | ||||
| 38 | - | func TestResolveTranscodeSource_UsesEagerFallback(t *testing.T) { | |||
| 39 | - | classifier := MediaClassifier{ | |||
| 40 | - | ParseSyntheticTranscodeURL: func(string) (string, string, string, bool) { return "", "", "", false }, | |||
| 41 | - | IsEagerTranscodeURL: func(string) bool { return true }, | |||
| 42 | - | }.normalize() | |||
| 43 | - | ||||
| 44 | - | src, referer, origin, eager := resolveTranscodeSource(classifier, "https://cdn.example/video.mp4") | |||
| 45 | - | require.True(t, eager) | |||
| 46 | - | require.Equal(t, "https://cdn.example/video.mp4", src) | |||
| 47 | - | require.Empty(t, referer) | |||
| 48 | - | require.Empty(t, origin) | |||
| 49 | - | } | |||
M internal/infrastructure/cef/webview_handlers.go +1 -1
| @@ -926,7 +926,7 @@ func (h *handlerSet) OnBeforeBrowse(browser purecef.Browser, frame purecef.Frame | |||
| 926 | 926 | } | |
| 927 | 927 | ||
| 928 | 928 | logging.FromContext(h.currentContext()).Debug(). | |
| 929 | - | Str("url", logging.TruncateURL(url, maxTranscodingURLLength)). | |
| 929 | + | Str("url", logging.TruncateURL(url, maxSchemeTruncatedURLLength)). | |
| 930 | 930 | Msg("cef: forcing download for navigation") | |
| 931 | 931 | ||
| 932 | 932 | host.StartDownload(url) | |
| @@ -926,7 +926,7 @@ func (h *handlerSet) OnBeforeBrowse(browser purecef.Browser, frame purecef.Frame | |||||
| 926 | } | 926 | } | ||
| 927 | 927 | ||||
| 928 | logging.FromContext(h.currentContext()).Debug(). | 928 | logging.FromContext(h.currentContext()).Debug(). | ||
| 929 | - | Str("url", logging.TruncateURL(url, maxTranscodingURLLength)). | 929 | + | Str("url", logging.TruncateURL(url, maxSchemeTruncatedURLLength)). |
| 930 | Msg("cef: forcing download for navigation") | 930 | Msg("cef: forcing download for navigation") | ||
| 931 | 931 | ||||
| 932 | host.StartDownload(url) | 932 | host.StartDownload(url) | ||
M internal/infrastructure/config/schema.go +0 -12
| @@ -382,16 +382,4 @@ type DownloadsConfig struct { | |||
| 382 | 382 | Path string `mapstructure:"path" yaml:"path" toml:"path"` | |
| 383 | 383 | } | |
| 384 | 384 | ||
| 385 | - | // Deprecated: TranscodingConfig is retained only for build compatibility with | |
| 386 | - | // the internal/infrastructure/transcoder package (to be deleted in p3-delete). | |
| 387 | - | // No public config schema or defaults are registered for it. | |
| 388 | - | // | |
| 389 | - | // TranscodingConfig controls GPU-accelerated media transcoding. | |
| 390 | - | type TranscodingConfig struct { | |
| 391 | - | Enabled bool `mapstructure:"enabled" yaml:"enabled" toml:"enabled"` | |
| 392 | - | HWAccel string `mapstructure:"hwaccel" yaml:"hwaccel" toml:"hwaccel"` | |
| 393 | - | MaxConcurrent int `mapstructure:"max_concurrent" yaml:"max_concurrent" toml:"max_concurrent"` | |
| 394 | - | Quality string `mapstructure:"quality" yaml:"quality" toml:"quality"` | |
| 395 | - | } | |
| 396 | - | ||
| 397 | 385 | ||
| @@ -382,16 +382,4 @@ type DownloadsConfig struct { | |||||
| 382 | Path string `mapstructure:"path" yaml:"path" toml:"path"` | 382 | Path string `mapstructure:"path" yaml:"path" toml:"path"` | ||
| 383 | } | 383 | } | ||
| 384 | 384 | ||||
| 385 | - | // Deprecated: TranscodingConfig is retained only for build compatibility with | |||
| 386 | - | // the internal/infrastructure/transcoder package (to be deleted in p3-delete). | |||
| 387 | - | // No public config schema or defaults are registered for it. | |||
| 388 | - | // | |||
| 389 | - | // TranscodingConfig controls GPU-accelerated media transcoding. | |||
| 390 | - | type TranscodingConfig struct { | |||
| 391 | - | Enabled bool `mapstructure:"enabled" yaml:"enabled" toml:"enabled"` | |||
| 392 | - | HWAccel string `mapstructure:"hwaccel" yaml:"hwaccel" toml:"hwaccel"` | |||
| 393 | - | MaxConcurrent int `mapstructure:"max_concurrent" yaml:"max_concurrent" toml:"max_concurrent"` | |||
| 394 | - | Quality string `mapstructure:"quality" yaml:"quality" toml:"quality"` | |||
| 395 | - | } | |||
| 396 | - | ||||
| 397 | 385 | ||||
D internal/infrastructure/netguard/transcode.go +0 -52
| @@ -1,52 +0,0 @@ | |||
| 1 | - | package netguard | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "net" | |
| 5 | - | "net/netip" | |
| 6 | - | ) | |
| 7 | - | ||
| 8 | - | var blockedTranscodeIPPfxs = []netip.Prefix{ | |
| 9 | - | netip.MustParsePrefix("0.0.0.0/8"), | |
| 10 | - | netip.MustParsePrefix("10.0.0.0/8"), | |
| 11 | - | netip.MustParsePrefix("100.64.0.0/10"), | |
| 12 | - | netip.MustParsePrefix("127.0.0.0/8"), | |
| 13 | - | netip.MustParsePrefix("169.254.0.0/16"), | |
| 14 | - | netip.MustParsePrefix("172.16.0.0/12"), | |
| 15 | - | netip.MustParsePrefix("192.0.0.0/24"), | |
| 16 | - | netip.MustParsePrefix("192.0.2.0/24"), | |
| 17 | - | netip.MustParsePrefix("192.168.0.0/16"), | |
| 18 | - | netip.MustParsePrefix("198.18.0.0/15"), | |
| 19 | - | netip.MustParsePrefix("198.51.100.0/24"), | |
| 20 | - | netip.MustParsePrefix("203.0.113.0/24"), | |
| 21 | - | netip.MustParsePrefix("224.0.0.0/4"), | |
| 22 | - | netip.MustParsePrefix("240.0.0.0/4"), | |
| 23 | - | netip.MustParsePrefix("::/128"), | |
| 24 | - | netip.MustParsePrefix("::1/128"), | |
| 25 | - | netip.MustParsePrefix("64:ff9b::/96"), | |
| 26 | - | netip.MustParsePrefix("64:ff9b:1::/48"), | |
| 27 | - | netip.MustParsePrefix("100::/64"), | |
| 28 | - | netip.MustParsePrefix("2001::/32"), | |
| 29 | - | netip.MustParsePrefix("2001:2::/48"), | |
| 30 | - | netip.MustParsePrefix("2001:db8::/32"), | |
| 31 | - | netip.MustParsePrefix("2002::/16"), | |
| 32 | - | netip.MustParsePrefix("fc00::/7"), | |
| 33 | - | netip.MustParsePrefix("fe80::/10"), | |
| 34 | - | netip.MustParsePrefix("ff00::/8"), | |
| 35 | - | } | |
| 36 | - | ||
| 37 | - | // IsBlockedTranscodeIP reports whether ip belongs to a local, private, | |
| 38 | - | // documentation, transition, or otherwise special-purpose range that a media | |
| 39 | - | // transcoder must not fetch on behalf of web content. | |
| 40 | - | func IsBlockedTranscodeIP(ip net.IP) bool { | |
| 41 | - | addr, ok := netip.AddrFromSlice(ip) | |
| 42 | - | if !ok { | |
| 43 | - | return true | |
| 44 | - | } | |
| 45 | - | addr = addr.Unmap() | |
| 46 | - | for _, prefix := range blockedTranscodeIPPfxs { | |
| 47 | - | if prefix.Contains(addr) { | |
| 48 | - | return true | |
| 49 | - | } | |
| 50 | - | } | |
| 51 | - | return false | |
| 52 | - | } | |
| @@ -1,52 +0,0 @@ | |||||
| 1 | - | package netguard | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "net" | |||
| 5 | - | "net/netip" | |||
| 6 | - | ) | |||
| 7 | - | ||||
| 8 | - | var blockedTranscodeIPPfxs = []netip.Prefix{ | |||
| 9 | - | netip.MustParsePrefix("0.0.0.0/8"), | |||
| 10 | - | netip.MustParsePrefix("10.0.0.0/8"), | |||
| 11 | - | netip.MustParsePrefix("100.64.0.0/10"), | |||
| 12 | - | netip.MustParsePrefix("127.0.0.0/8"), | |||
| 13 | - | netip.MustParsePrefix("169.254.0.0/16"), | |||
| 14 | - | netip.MustParsePrefix("172.16.0.0/12"), | |||
| 15 | - | netip.MustParsePrefix("192.0.0.0/24"), | |||
| 16 | - | netip.MustParsePrefix("192.0.2.0/24"), | |||
| 17 | - | netip.MustParsePrefix("192.168.0.0/16"), | |||
| 18 | - | netip.MustParsePrefix("198.18.0.0/15"), | |||
| 19 | - | netip.MustParsePrefix("198.51.100.0/24"), | |||
| 20 | - | netip.MustParsePrefix("203.0.113.0/24"), | |||
| 21 | - | netip.MustParsePrefix("224.0.0.0/4"), | |||
| 22 | - | netip.MustParsePrefix("240.0.0.0/4"), | |||
| 23 | - | netip.MustParsePrefix("::/128"), | |||
| 24 | - | netip.MustParsePrefix("::1/128"), | |||
| 25 | - | netip.MustParsePrefix("64:ff9b::/96"), | |||
| 26 | - | netip.MustParsePrefix("64:ff9b:1::/48"), | |||
| 27 | - | netip.MustParsePrefix("100::/64"), | |||
| 28 | - | netip.MustParsePrefix("2001::/32"), | |||
| 29 | - | netip.MustParsePrefix("2001:2::/48"), | |||
| 30 | - | netip.MustParsePrefix("2001:db8::/32"), | |||
| 31 | - | netip.MustParsePrefix("2002::/16"), | |||
| 32 | - | netip.MustParsePrefix("fc00::/7"), | |||
| 33 | - | netip.MustParsePrefix("fe80::/10"), | |||
| 34 | - | netip.MustParsePrefix("ff00::/8"), | |||
| 35 | - | } | |||
| 36 | - | ||||
| 37 | - | // IsBlockedTranscodeIP reports whether ip belongs to a local, private, | |||
| 38 | - | // documentation, transition, or otherwise special-purpose range that a media | |||
| 39 | - | // transcoder must not fetch on behalf of web content. | |||
| 40 | - | func IsBlockedTranscodeIP(ip net.IP) bool { | |||
| 41 | - | addr, ok := netip.AddrFromSlice(ip) | |||
| 42 | - | if !ok { | |||
| 43 | - | return true | |||
| 44 | - | } | |||
| 45 | - | addr = addr.Unmap() | |||
| 46 | - | for _, prefix := range blockedTranscodeIPPfxs { | |||
| 47 | - | if prefix.Contains(addr) { | |||
| 48 | - | return true | |||
| 49 | - | } | |||
| 50 | - | } | |||
| 51 | - | return false | |||
| 52 | - | } | |||
D internal/infrastructure/netguard/transcode_test.go +0 -50
| @@ -1,50 +0,0 @@ | |||
| 1 | - | package netguard | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "net" | |
| 5 | - | "testing" | |
| 6 | - | ) | |
| 7 | - | ||
| 8 | - | func TestIsBlockedTranscodeIPRejectsSpecialPurposeRanges(t *testing.T) { | |
| 9 | - | tests := []string{ | |
| 10 | - | "127.0.0.1", | |
| 11 | - | "10.0.0.1", | |
| 12 | - | "172.16.0.1", | |
| 13 | - | "192.168.1.1", | |
| 14 | - | "100.64.0.1", | |
| 15 | - | "198.18.0.1", | |
| 16 | - | "192.0.2.1", | |
| 17 | - | "169.254.169.254", | |
| 18 | - | "0.0.0.0", | |
| 19 | - | "64:ff9b::a00:1", | |
| 20 | - | "2001::1", | |
| 21 | - | "2001:db8::1", | |
| 22 | - | "2002:0a00:0001::1", | |
| 23 | - | "fc00::1", | |
| 24 | - | "fe80::1", | |
| 25 | - | "::ffff:127.0.0.1", | |
| 26 | - | } | |
| 27 | - | for _, rawIP := range tests { | |
| 28 | - | t.Run(rawIP, func(t *testing.T) { | |
| 29 | - | if !IsBlockedTranscodeIP(net.ParseIP(rawIP)) { | |
| 30 | - | t.Fatalf("IsBlockedTranscodeIP(%s) = false, want true", rawIP) | |
| 31 | - | } | |
| 32 | - | }) | |
| 33 | - | } | |
| 34 | - | } | |
| 35 | - | ||
| 36 | - | func TestIsBlockedTranscodeIPRejectsInvalidIP(t *testing.T) { | |
| 37 | - | if !IsBlockedTranscodeIP(net.ParseIP("")) { | |
| 38 | - | t.Fatal("IsBlockedTranscodeIP(invalid IP) = false, want true") | |
| 39 | - | } | |
| 40 | - | } | |
| 41 | - | ||
| 42 | - | func TestIsBlockedTranscodeIPAllowsPublicAddress(t *testing.T) { | |
| 43 | - | for _, rawIP := range []string{"93.184.216.34", "2606:4700:4700::1111"} { | |
| 44 | - | t.Run(rawIP, func(t *testing.T) { | |
| 45 | - | if IsBlockedTranscodeIP(net.ParseIP(rawIP)) { | |
| 46 | - | t.Fatalf("IsBlockedTranscodeIP(%s) = true, want false", rawIP) | |
| 47 | - | } | |
| 48 | - | }) | |
| 49 | - | } | |
| 50 | - | } | |
| @@ -1,50 +0,0 @@ | |||||
| 1 | - | package netguard | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "net" | |||
| 5 | - | "testing" | |||
| 6 | - | ) | |||
| 7 | - | ||||
| 8 | - | func TestIsBlockedTranscodeIPRejectsSpecialPurposeRanges(t *testing.T) { | |||
| 9 | - | tests := []string{ | |||
| 10 | - | "127.0.0.1", | |||
| 11 | - | "10.0.0.1", | |||
| 12 | - | "172.16.0.1", | |||
| 13 | - | "192.168.1.1", | |||
| 14 | - | "100.64.0.1", | |||
| 15 | - | "198.18.0.1", | |||
| 16 | - | "192.0.2.1", | |||
| 17 | - | "169.254.169.254", | |||
| 18 | - | "0.0.0.0", | |||
| 19 | - | "64:ff9b::a00:1", | |||
| 20 | - | "2001::1", | |||
| 21 | - | "2001:db8::1", | |||
| 22 | - | "2002:0a00:0001::1", | |||
| 23 | - | "fc00::1", | |||
| 24 | - | "fe80::1", | |||
| 25 | - | "::ffff:127.0.0.1", | |||
| 26 | - | } | |||
| 27 | - | for _, rawIP := range tests { | |||
| 28 | - | t.Run(rawIP, func(t *testing.T) { | |||
| 29 | - | if !IsBlockedTranscodeIP(net.ParseIP(rawIP)) { | |||
| 30 | - | t.Fatalf("IsBlockedTranscodeIP(%s) = false, want true", rawIP) | |||
| 31 | - | } | |||
| 32 | - | }) | |||
| 33 | - | } | |||
| 34 | - | } | |||
| 35 | - | ||||
| 36 | - | func TestIsBlockedTranscodeIPRejectsInvalidIP(t *testing.T) { | |||
| 37 | - | if !IsBlockedTranscodeIP(net.ParseIP("")) { | |||
| 38 | - | t.Fatal("IsBlockedTranscodeIP(invalid IP) = false, want true") | |||
| 39 | - | } | |||
| 40 | - | } | |||
| 41 | - | ||||
| 42 | - | func TestIsBlockedTranscodeIPAllowsPublicAddress(t *testing.T) { | |||
| 43 | - | for _, rawIP := range []string{"93.184.216.34", "2606:4700:4700::1111"} { | |||
| 44 | - | t.Run(rawIP, func(t *testing.T) { | |||
| 45 | - | if IsBlockedTranscodeIP(net.ParseIP(rawIP)) { | |||
| 46 | - | t.Fatalf("IsBlockedTranscodeIP(%s) = true, want false", rawIP) | |||
| 47 | - | } | |||
| 48 | - | }) | |||
| 49 | - | } | |||
| 50 | - | } | |||
D internal/infrastructure/transcoder/audio.go +0 -417
| @@ -1,417 +0,0 @@ | |||
| 1 | - | //go:build !js || !wasm | |
| 2 | - | ||
| 3 | - | package transcoder | |
| 4 | - | ||
| 5 | - | import ( | |
| 6 | - | "errors" | |
| 7 | - | "fmt" | |
| 8 | - | "unsafe" | |
| 9 | - | ||
| 10 | - | "github.com/bnema/purego-ffmpeg/ffmpeg" | |
| 11 | - | "github.com/rs/zerolog" | |
| 12 | - | ) | |
| 13 | - | ||
| 14 | - | // opusSampleRate is the standard output sample rate for Opus encoding. | |
| 15 | - | // Opus supports 8, 12, 16, 24, and 48 kHz. 48 kHz is the canonical rate. | |
| 16 | - | const opusSampleRate = 48000 | |
| 17 | - | ||
| 18 | - | // opusBitrate is the default Opus encoding bitrate (128 kbps stereo). | |
| 19 | - | const opusBitrate = 128000 | |
| 20 | - | ||
| 21 | - | // opusFrameSize is the number of samples per Opus frame at 48 kHz. | |
| 22 | - | // 960 samples = 20 ms at 48 kHz, which is the standard Opus frame duration. | |
| 23 | - | const opusFrameSize = 960 | |
| 24 | - | ||
| 25 | - | func freeCodecContext(ctx *unsafe.Pointer) { | |
| 26 | - | //nolint:gosec // FFmpeg expects AVCodecContext** for ownership release. | |
| 27 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(ctx)) | |
| 28 | - | } | |
| 29 | - | ||
| 30 | - | func freeFrame(frame *unsafe.Pointer) { | |
| 31 | - | //nolint:gosec // FFmpeg expects AVFrame** for ownership release. | |
| 32 | - | ffmpeg.FrameFree(unsafe.Pointer(frame)) | |
| 33 | - | } | |
| 34 | - | ||
| 35 | - | func freeSwrContext(swr *unsafe.Pointer) { | |
| 36 | - | //nolint:gosec // FFmpeg expects SwrContext** for ownership release. | |
| 37 | - | ffmpeg.SwrFree(unsafe.Pointer(swr)) | |
| 38 | - | } | |
| 39 | - | ||
| 40 | - | func freePacket(pkt *unsafe.Pointer) { | |
| 41 | - | //nolint:gosec // FFmpeg expects AVPacket** for ownership release. | |
| 42 | - | ffmpeg.PacketFree(unsafe.Pointer(pkt)) | |
| 43 | - | } | |
| 44 | - | ||
| 45 | - | func swrConvert(swr, outFrame, inFrame unsafe.Pointer, outSamples, inSamples int32) int32 { | |
| 46 | - | //nolint:gosec // FFmpeg expects raw AVFrame data pointers here. | |
| 47 | - | return ffmpeg.SwrConvert( | |
| 48 | - | swr, | |
| 49 | - | unsafe.Pointer((*unsafe.Pointer)(outFrame)), outSamples, | |
| 50 | - | unsafe.Pointer((*unsafe.Pointer)(inFrame)), inSamples, | |
| 51 | - | ) | |
| 52 | - | } | |
| 53 | - | ||
| 54 | - | // audioTranscoder decodes an input audio stream (typically AAC) and | |
| 55 | - | // re-encodes it to Opus, performing sample rate conversion if needed. | |
| 56 | - | type audioTranscoder struct { | |
| 57 | - | decoder unsafe.Pointer // AVCodecContext* for decoding | |
| 58 | - | encoder unsafe.Pointer // AVCodecContext* for Opus encoding | |
| 59 | - | resampler unsafe.Pointer // SwrContext* (nil if no resampling needed) | |
| 60 | - | frame unsafe.Pointer // reusable AVFrame for resampled output | |
| 61 | - | decFrame unsafe.Pointer // reusable AVFrame for decoded output | |
| 62 | - | encPkt unsafe.Pointer // reusable AVPacket for encoded output | |
| 63 | - | inStream int // input stream index in the source container | |
| 64 | - | outStream int // output stream index in the muxer | |
| 65 | - | } | |
| 66 | - | ||
| 67 | - | // newAudioTranscoder sets up an AAC (or other) decoder and a libopus encoder. | |
| 68 | - | // inCodecPar is the AVCodecParameters* from the input audio stream. | |
| 69 | - | // outFmtCtx is the AVFormatContext* for the output container (used to | |
| 70 | - | // add the output stream). inStreamIdx is the input stream index. | |
| 71 | - | func newAudioTranscoder(inCodecPar, outFmtCtx unsafe.Pointer, inStreamIdx int) (*audioTranscoder, error) { | |
| 72 | - | return newAudioTranscoderImpl(inCodecPar, outFmtCtx, inStreamIdx) | |
| 73 | - | } | |
| 74 | - | ||
| 75 | - | //nolint:gocyclo,funlen // FFmpeg decoder/encoder/resampler setup needs the necessary branches. | |
| 76 | - | func newAudioTranscoderImpl(inCodecPar, outFmtCtx unsafe.Pointer, inStreamIdx int) (*audioTranscoder, error) { | |
| 77 | - | // --- Decoder setup --- | |
| 78 | - | codecID := ffmpeg.CodecParCodecID(inCodecPar) | |
| 79 | - | dec := ffmpeg.CodecFindDecoder(codecID) | |
| 80 | - | if dec == nil { | |
| 81 | - | return nil, fmt.Errorf("audio decoder not found for codec ID %d", codecID) | |
| 82 | - | } | |
| 83 | - | ||
| 84 | - | decCtx := ffmpeg.CodecAllocContext3(dec) | |
| 85 | - | if decCtx == nil { | |
| 86 | - | return nil, errors.New("failed to allocate audio decoder context") | |
| 87 | - | } | |
| 88 | - | ||
| 89 | - | if ret := ffmpeg.CodecParametersToContext(decCtx, inCodecPar); ret < 0 { | |
| 90 | - | freeCodecContext(&decCtx) | |
| 91 | - | return nil, fmt.Errorf("failed to copy audio codec parameters to decoder: %d", ret) | |
| 92 | - | } | |
| 93 | - | ||
| 94 | - | if ret := ffmpeg.CodecOpen2(decCtx, dec, nil); ret < 0 { | |
| 95 | - | freeCodecContext(&decCtx) | |
| 96 | - | return nil, fmt.Errorf("failed to open audio decoder: %d", ret) | |
| 97 | - | } | |
| 98 | - | ||
| 99 | - | // --- Encoder setup (libopus) --- | |
| 100 | - | enc := ffmpeg.CodecFindEncoderByName("libopus") | |
| 101 | - | if enc == nil { | |
| 102 | - | freeCodecContext(&decCtx) | |
| 103 | - | return nil, errors.New("libopus encoder not found") | |
| 104 | - | } | |
| 105 | - | ||
| 106 | - | encCtx := ffmpeg.CodecAllocContext3(enc) | |
| 107 | - | if encCtx == nil { | |
| 108 | - | freeCodecContext(&decCtx) | |
| 109 | - | return nil, errors.New("failed to allocate audio encoder context") | |
| 110 | - | } | |
| 111 | - | ||
| 112 | - | // libopus accepts s16 (interleaved) and flt (float interleaved). | |
| 113 | - | // Planar formats (fltp) are NOT supported and cause encoder open to fail. | |
| 114 | - | ffmpeg.CodecCtxSetSampleFmt(encCtx, int32(ffmpeg.SampleFmtFlt)) | |
| 115 | - | ffmpeg.CodecCtxSetSampleRate(encCtx, opusSampleRate) | |
| 116 | - | codecCtxSetBitRate(encCtx, opusBitrate) | |
| 117 | - | ||
| 118 | - | // Copy channel layout from decoder to encoder. | |
| 119 | - | codecCtxCopyChLayout(encCtx, decCtx) | |
| 120 | - | ||
| 121 | - | // Set time base to 1/sample_rate for audio. | |
| 122 | - | ffmpeg.CodecCtxSetTimeBase(encCtx, ffmpeg.AVRational{Num: 1, Den: opusSampleRate}) | |
| 123 | - | ||
| 124 | - | // Note: WebM container does not require AV_CODEC_FLAG_GLOBAL_HEADER for Opus. | |
| 125 | - | ||
| 126 | - | if ret := ffmpeg.CodecOpen2(encCtx, enc, nil); ret < 0 { | |
| 127 | - | freeCodecContext(&encCtx) | |
| 128 | - | freeCodecContext(&decCtx) | |
| 129 | - | return nil, fmt.Errorf("failed to open audio encoder: %d", ret) | |
| 130 | - | } | |
| 131 | - | ||
| 132 | - | // --- Add output stream --- | |
| 133 | - | outStreamPtr := ffmpeg.FormatNewStream(outFmtCtx, nil) | |
| 134 | - | if outStreamPtr == nil { | |
| 135 | - | freeCodecContext(&encCtx) | |
| 136 | - | freeCodecContext(&decCtx) | |
| 137 | - | return nil, errors.New("failed to create output audio stream") | |
| 138 | - | } | |
| 139 | - | ||
| 140 | - | outStreamWrap := ffmpeg.WrapStream(outStreamPtr) | |
| 141 | - | outStreamIdx := int(outStreamWrap.Index()) | |
| 142 | - | ||
| 143 | - | // Copy encoder parameters to the output stream's codecpar. | |
| 144 | - | if ret := ffmpeg.CodecParametersFromContext(outStreamWrap.Codecpar(), encCtx); ret < 0 { | |
| 145 | - | freeCodecContext(&encCtx) | |
| 146 | - | freeCodecContext(&decCtx) | |
| 147 | - | return nil, fmt.Errorf("failed to copy audio encoder params to stream: %d", ret) | |
| 148 | - | } | |
| 149 | - | ||
| 150 | - | // --- Resampler setup (if sample rates differ) --- | |
| 151 | - | var swr unsafe.Pointer | |
| 152 | - | var resampledFrame unsafe.Pointer | |
| 153 | - | ||
| 154 | - | inSampleRate := ffmpeg.CodecCtxSampleRate(decCtx) | |
| 155 | - | inSampleFmt := ffmpeg.CodecCtxSampleFmt(decCtx) | |
| 156 | - | needResample := inSampleRate != opusSampleRate || inSampleFmt != int32(ffmpeg.SampleFmtFlt) | |
| 157 | - | ||
| 158 | - | if needResample { | |
| 159 | - | swr = ffmpeg.SwrAlloc() | |
| 160 | - | if swr == nil { | |
| 161 | - | freeCodecContext(&encCtx) | |
| 162 | - | freeCodecContext(&decCtx) | |
| 163 | - | return nil, errors.New("failed to allocate resampler") | |
| 164 | - | } | |
| 165 | - | ||
| 166 | - | // Configure resampler via av_opt_set on the SwrContext. | |
| 167 | - | ffmpeg.AVOptSetInt(swr, "in_sample_rate", int64(inSampleRate), 0) | |
| 168 | - | ffmpeg.AVOptSetInt(swr, "out_sample_rate", int64(opusSampleRate), 0) | |
| 169 | - | ffmpeg.AVOptSet(swr, "in_sample_fmt", ffmpeg.GetSampleFmtName(inSampleFmt), 0) | |
| 170 | - | ffmpeg.AVOptSet(swr, "out_sample_fmt", ffmpeg.GetSampleFmtName(int32(ffmpeg.SampleFmtFlt)), 0) | |
| 171 | - | ||
| 172 | - | // Modern FFmpeg (8.x) requires explicit channel layout configuration | |
| 173 | - | // on the SwrContext. Without it, swr_init may succeed but the resampler | |
| 174 | - | // operates with 0 channels and produces silence. Copy the decoder's | |
| 175 | - | // channel layout to both in and out (we keep them identical — same | |
| 176 | - | // number of channels, only sample rate/format changes). | |
| 177 | - | swrSetChLayoutFromCodecCtx(swr, decCtx, offsetSwrInChLayout) | |
| 178 | - | swrSetChLayoutFromCodecCtx(swr, decCtx, offsetSwrOutChLayout) | |
| 179 | - | ||
| 180 | - | if ret := ffmpeg.SwrInit(swr); ret < 0 { | |
| 181 | - | freeSwrContext(&swr) | |
| 182 | - | freeCodecContext(&encCtx) | |
| 183 | - | freeCodecContext(&decCtx) | |
| 184 | - | return nil, fmt.Errorf("failed to init resampler: %d", ret) | |
| 185 | - | } | |
| 186 | - | ||
| 187 | - | // Allocate a reusable output frame for resampled data. | |
| 188 | - | resampledFrame = ffmpeg.FrameAlloc() | |
| 189 | - | if resampledFrame == nil { | |
| 190 | - | freeSwrContext(&swr) | |
| 191 | - | freeCodecContext(&encCtx) | |
| 192 | - | freeCodecContext(&decCtx) | |
| 193 | - | return nil, errors.New("failed to allocate resampled frame") | |
| 194 | - | } | |
| 195 | - | ||
| 196 | - | frameSetFormat(resampledFrame, int32(ffmpeg.SampleFmtFlt)) | |
| 197 | - | frameSetSampleRate(resampledFrame, opusSampleRate) | |
| 198 | - | frameSetNbSamples(resampledFrame, opusFrameSize) | |
| 199 | - | // Copy channel layout from decoder to the resampled frame. | |
| 200 | - | codecCtxCopyChLayout(resampledFrame, decCtx) | |
| 201 | - | } | |
| 202 | - | ||
| 203 | - | decFrame := ffmpeg.FrameAlloc() | |
| 204 | - | if decFrame == nil { | |
| 205 | - | if resampledFrame != nil { | |
| 206 | - | freeFrame(&resampledFrame) | |
| 207 | - | } | |
| 208 | - | if swr != nil { | |
| 209 | - | freeSwrContext(&swr) | |
| 210 | - | } | |
| 211 | - | freeCodecContext(&encCtx) | |
| 212 | - | freeCodecContext(&decCtx) | |
| 213 | - | return nil, errors.New("failed to allocate audio decode frame") | |
| 214 | - | } | |
| 215 | - | ||
| 216 | - | encPkt := ffmpeg.PacketAlloc() | |
| 217 | - | if encPkt == nil { | |
| 218 | - | freeFrame(&decFrame) | |
| 219 | - | if resampledFrame != nil { | |
| 220 | - | freeFrame(&resampledFrame) | |
| 221 | - | } | |
| 222 | - | if swr != nil { | |
| 223 | - | freeSwrContext(&swr) | |
| 224 | - | } | |
| 225 | - | freeCodecContext(&encCtx) | |
| 226 | - | freeCodecContext(&decCtx) | |
| 227 | - | return nil, errors.New("failed to allocate audio encode packet") | |
| 228 | - | } | |
| 229 | - | ||
| 230 | - | return &audioTranscoder{ | |
| 231 | - | decoder: decCtx, | |
| 232 | - | encoder: encCtx, | |
| 233 | - | resampler: swr, | |
| 234 | - | frame: resampledFrame, | |
| 235 | - | decFrame: decFrame, | |
| 236 | - | encPkt: encPkt, | |
| 237 | - | inStream: inStreamIdx, | |
| 238 | - | outStream: outStreamIdx, | |
| 239 | - | }, nil | |
| 240 | - | } | |
| 241 | - | ||
| 242 | - | // processPacket decodes an audio packet, optionally resamples, encodes | |
| 243 | - | // to Opus, and writes the resulting packets to outFmtCtx. | |
| 244 | - | func (a *audioTranscoder) processPacket(pkt, outFmtCtx unsafe.Pointer) error { | |
| 245 | - | // Send packet to decoder. | |
| 246 | - | sendRet := ffmpeg.CodecSendPacket(a.decoder, pkt) | |
| 247 | - | if sendRet < 0 { | |
| 248 | - | return fmt.Errorf("audio decode send packet: %d", sendRet) | |
| 249 | - | } | |
| 250 | - | ||
| 251 | - | frame := a.decFrame | |
| 252 | - | encPkt := a.encPkt | |
| 253 | - | ||
| 254 | - | for { | |
| 255 | - | recvRet := ffmpeg.CodecReceiveFrame(a.decoder, frame) | |
| 256 | - | if recvRet < 0 { | |
| 257 | - | // EAGAIN or EOF — no more frames from this packet. | |
| 258 | - | break | |
| 259 | - | } | |
| 260 | - | ||
| 261 | - | // Determine which frame to send to the encoder. | |
| 262 | - | encFrame := frame | |
| 263 | - | if a.resampler != nil { | |
| 264 | - | encFrame = a.frame | |
| 265 | - | ||
| 266 | - | inSamples := frameNbSamples(frame) | |
| 267 | - | ||
| 268 | - | // Allocate buffer for output frame if needed. | |
| 269 | - | frameSetNbSamples(a.frame, opusFrameSize) | |
| 270 | - | if bufferRet := ffmpeg.FrameGetBuffer(a.frame, 0); bufferRet < 0 { | |
| 271 | - | ffmpeg.FrameUnref(frame) | |
| 272 | - | return fmt.Errorf("failed to allocate resampled frame buffer: %d", bufferRet) | |
| 273 | - | } | |
| 274 | - | ||
| 275 | - | converted := swrConvert(a.resampler, a.frame, frame, opusFrameSize, inSamples) | |
| 276 | - | if converted < 0 { | |
| 277 | - | ffmpeg.FrameUnref(frame) | |
| 278 | - | return fmt.Errorf("audio resample failed: %d", converted) | |
| 279 | - | } | |
| 280 | - | frameSetNbSamples(a.frame, converted) | |
| 281 | - | ||
| 282 | - | // Copy pts from decoded frame, rescaling for the new sample rate. | |
| 283 | - | pts := framePts(frame) | |
| 284 | - | if pts >= 0 { | |
| 285 | - | decTB := ffmpeg.CodecCtxTimeBase(a.decoder) | |
| 286 | - | encTB := ffmpeg.CodecCtxTimeBase(a.encoder) | |
| 287 | - | frameSetPts(a.frame, ffmpeg.RescaleQ(pts, decTB, encTB)) | |
| 288 | - | } | |
| 289 | - | } | |
| 290 | - | ||
| 291 | - | // Send frame to encoder. | |
| 292 | - | if sendFrameRet := ffmpeg.CodecSendFrame(a.encoder, encFrame); sendFrameRet < 0 { | |
| 293 | - | ffmpeg.FrameUnref(frame) | |
| 294 | - | return fmt.Errorf("audio encode send frame: %d", sendFrameRet) | |
| 295 | - | } | |
| 296 | - | ||
| 297 | - | // Receive encoded packets. | |
| 298 | - | for { | |
| 299 | - | recvPktRet := ffmpeg.CodecReceivePacket(a.encoder, encPkt) | |
| 300 | - | if recvPktRet < 0 { | |
| 301 | - | break | |
| 302 | - | } | |
| 303 | - | ||
| 304 | - | pktSetStreamIndex(encPkt, int32(a.outStream)) | |
| 305 | - | ||
| 306 | - | // Rescale timestamps from encoder time base to output stream time base. | |
| 307 | - | outStream := ffmpeg.FmtCtxStream(outFmtCtx, a.outStream) | |
| 308 | - | outStreamWrap := ffmpeg.WrapStream(outStream) | |
| 309 | - | ffmpeg.PacketRescaleTs(encPkt, ffmpeg.CodecCtxTimeBase(a.encoder), outStreamWrap.TimeBase()) | |
| 310 | - | ||
| 311 | - | if wret := ffmpeg.InterleavedWriteFrame(outFmtCtx, encPkt); wret < 0 { | |
| 312 | - | ffmpeg.PacketUnref(encPkt) | |
| 313 | - | ffmpeg.FrameUnref(frame) | |
| 314 | - | return fmt.Errorf("audio write frame: %d", wret) | |
| 315 | - | } | |
| 316 | - | ffmpeg.PacketUnref(encPkt) | |
| 317 | - | } | |
| 318 | - | ||
| 319 | - | ffmpeg.FrameUnref(frame) | |
| 320 | - | } | |
| 321 | - | ||
| 322 | - | return nil | |
| 323 | - | } | |
| 324 | - | ||
| 325 | - | // flush drains remaining frames from the decoder and encoder. | |
| 326 | - | // The logger parameter is used to report non-fatal resampling errors | |
| 327 | - | // that occur during flush (we continue flushing rather than aborting). | |
| 328 | - | func (a *audioTranscoder) flush(outFmtCtx unsafe.Pointer, logger zerolog.Logger) { | |
| 329 | - | // Flush decoder by sending NULL packet. | |
| 330 | - | ffmpeg.CodecSendPacket(a.decoder, nil) | |
| 331 | - | ||
| 332 | - | frame := a.decFrame | |
| 333 | - | encPkt := a.encPkt | |
| 334 | - | ||
| 335 | - | // Drain decoded frames. | |
| 336 | - | for { | |
| 337 | - | recvRet := ffmpeg.CodecReceiveFrame(a.decoder, frame) | |
| 338 | - | if recvRet < 0 { | |
| 339 | - | break | |
| 340 | - | } | |
| 341 | - | ||
| 342 | - | encFrame := frame | |
| 343 | - | if a.resampler != nil { | |
| 344 | - | encFrame = a.frame | |
| 345 | - | inSamples := frameNbSamples(frame) | |
| 346 | - | ||
| 347 | - | frameSetNbSamples(a.frame, opusFrameSize) | |
| 348 | - | if bufferRet := ffmpeg.FrameGetBuffer(a.frame, 0); bufferRet < 0 { | |
| 349 | - | logger.Warn().Int32("ret", bufferRet).Msg("failed to allocate resampled frame buffer during flush") | |
| 350 | - | ffmpeg.FrameUnref(frame) | |
| 351 | - | continue | |
| 352 | - | } | |
| 353 | - | ||
| 354 | - | converted := swrConvert(a.resampler, a.frame, frame, opusFrameSize, inSamples) | |
| 355 | - | if converted < 0 { | |
| 356 | - | logger.Warn().Int32("ret", converted).Msg("audio resample failed during flush") | |
| 357 | - | ffmpeg.FrameUnref(frame) | |
| 358 | - | continue | |
| 359 | - | } | |
| 360 | - | frameSetNbSamples(a.frame, converted) | |
| 361 | - | } | |
| 362 | - | ||
| 363 | - | ffmpeg.CodecSendFrame(a.encoder, encFrame) | |
| 364 | - | ||
| 365 | - | for { | |
| 366 | - | recvPktRet := ffmpeg.CodecReceivePacket(a.encoder, encPkt) | |
| 367 | - | if recvPktRet < 0 { | |
| 368 | - | break | |
| 369 | - | } | |
| 370 | - | pktSetStreamIndex(encPkt, int32(a.outStream)) | |
| 371 | - | outStream := ffmpeg.FmtCtxStream(outFmtCtx, a.outStream) | |
| 372 | - | outStreamWrap := ffmpeg.WrapStream(outStream) | |
| 373 | - | ffmpeg.PacketRescaleTs(encPkt, ffmpeg.CodecCtxTimeBase(a.encoder), outStreamWrap.TimeBase()) | |
| 374 | - | ffmpeg.InterleavedWriteFrame(outFmtCtx, encPkt) | |
| 375 | - | ffmpeg.PacketUnref(encPkt) | |
| 376 | - | } | |
| 377 | - | ||
| 378 | - | ffmpeg.FrameUnref(frame) | |
| 379 | - | } | |
| 380 | - | ||
| 381 | - | // Flush encoder by sending NULL frame. | |
| 382 | - | ffmpeg.CodecSendFrame(a.encoder, nil) | |
| 383 | - | for { | |
| 384 | - | flushPktRet := ffmpeg.CodecReceivePacket(a.encoder, encPkt) | |
| 385 | - | if flushPktRet < 0 { | |
| 386 | - | break | |
| 387 | - | } | |
| 388 | - | pktSetStreamIndex(encPkt, int32(a.outStream)) | |
| 389 | - | outStream := ffmpeg.FmtCtxStream(outFmtCtx, a.outStream) | |
| 390 | - | outStreamWrap := ffmpeg.WrapStream(outStream) | |
| 391 | - | ffmpeg.PacketRescaleTs(encPkt, ffmpeg.CodecCtxTimeBase(a.encoder), outStreamWrap.TimeBase()) | |
| 392 | - | ffmpeg.InterleavedWriteFrame(outFmtCtx, encPkt) | |
| 393 | - | ffmpeg.PacketUnref(encPkt) | |
| 394 | - | } | |
| 395 | - | } | |
| 396 | - | ||
| 397 | - | // close frees all FFmpeg resources owned by the audio transcoder. | |
| 398 | - | func (a *audioTranscoder) close() { | |
| 399 | - | if a.encPkt != nil { | |
| 400 | - | freePacket(&a.encPkt) | |
| 401 | - | } | |
| 402 | - | if a.decFrame != nil { | |
| 403 | - | freeFrame(&a.decFrame) | |
| 404 | - | } | |
| 405 | - | if a.frame != nil { | |
| 406 | - | freeFrame(&a.frame) | |
| 407 | - | } | |
| 408 | - | if a.resampler != nil { | |
| 409 | - | freeSwrContext(&a.resampler) | |
| 410 | - | } | |
| 411 | - | if a.encoder != nil { | |
| 412 | - | freeCodecContext(&a.encoder) | |
| 413 | - | } | |
| 414 | - | if a.decoder != nil { | |
| 415 | - | freeCodecContext(&a.decoder) | |
| 416 | - | } | |
| 417 | - | } | |
| @@ -1,417 +0,0 @@ | |||||
| 1 | - | //go:build !js || !wasm | |||
| 2 | - | ||||
| 3 | - | package transcoder | |||
| 4 | - | ||||
| 5 | - | import ( | |||
| 6 | - | "errors" | |||
| 7 | - | "fmt" | |||
| 8 | - | "unsafe" | |||
| 9 | - | ||||
| 10 | - | "github.com/bnema/purego-ffmpeg/ffmpeg" | |||
| 11 | - | "github.com/rs/zerolog" | |||
| 12 | - | ) | |||
| 13 | - | ||||
| 14 | - | // opusSampleRate is the standard output sample rate for Opus encoding. | |||
| 15 | - | // Opus supports 8, 12, 16, 24, and 48 kHz. 48 kHz is the canonical rate. | |||
| 16 | - | const opusSampleRate = 48000 | |||
| 17 | - | ||||
| 18 | - | // opusBitrate is the default Opus encoding bitrate (128 kbps stereo). | |||
| 19 | - | const opusBitrate = 128000 | |||
| 20 | - | ||||
| 21 | - | // opusFrameSize is the number of samples per Opus frame at 48 kHz. | |||
| 22 | - | // 960 samples = 20 ms at 48 kHz, which is the standard Opus frame duration. | |||
| 23 | - | const opusFrameSize = 960 | |||
| 24 | - | ||||
| 25 | - | func freeCodecContext(ctx *unsafe.Pointer) { | |||
| 26 | - | //nolint:gosec // FFmpeg expects AVCodecContext** for ownership release. | |||
| 27 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(ctx)) | |||
| 28 | - | } | |||
| 29 | - | ||||
| 30 | - | func freeFrame(frame *unsafe.Pointer) { | |||
| 31 | - | //nolint:gosec // FFmpeg expects AVFrame** for ownership release. | |||
| 32 | - | ffmpeg.FrameFree(unsafe.Pointer(frame)) | |||
| 33 | - | } | |||
| 34 | - | ||||
| 35 | - | func freeSwrContext(swr *unsafe.Pointer) { | |||
| 36 | - | //nolint:gosec // FFmpeg expects SwrContext** for ownership release. | |||
| 37 | - | ffmpeg.SwrFree(unsafe.Pointer(swr)) | |||
| 38 | - | } | |||
| 39 | - | ||||
| 40 | - | func freePacket(pkt *unsafe.Pointer) { | |||
| 41 | - | //nolint:gosec // FFmpeg expects AVPacket** for ownership release. | |||
| 42 | - | ffmpeg.PacketFree(unsafe.Pointer(pkt)) | |||
| 43 | - | } | |||
| 44 | - | ||||
| 45 | - | func swrConvert(swr, outFrame, inFrame unsafe.Pointer, outSamples, inSamples int32) int32 { | |||
| 46 | - | //nolint:gosec // FFmpeg expects raw AVFrame data pointers here. | |||
| 47 | - | return ffmpeg.SwrConvert( | |||
| 48 | - | swr, | |||
| 49 | - | unsafe.Pointer((*unsafe.Pointer)(outFrame)), outSamples, | |||
| 50 | - | unsafe.Pointer((*unsafe.Pointer)(inFrame)), inSamples, | |||
| 51 | - | ) | |||
| 52 | - | } | |||
| 53 | - | ||||
| 54 | - | // audioTranscoder decodes an input audio stream (typically AAC) and | |||
| 55 | - | // re-encodes it to Opus, performing sample rate conversion if needed. | |||
| 56 | - | type audioTranscoder struct { | |||
| 57 | - | decoder unsafe.Pointer // AVCodecContext* for decoding | |||
| 58 | - | encoder unsafe.Pointer // AVCodecContext* for Opus encoding | |||
| 59 | - | resampler unsafe.Pointer // SwrContext* (nil if no resampling needed) | |||
| 60 | - | frame unsafe.Pointer // reusable AVFrame for resampled output | |||
| 61 | - | decFrame unsafe.Pointer // reusable AVFrame for decoded output | |||
| 62 | - | encPkt unsafe.Pointer // reusable AVPacket for encoded output | |||
| 63 | - | inStream int // input stream index in the source container | |||
| 64 | - | outStream int // output stream index in the muxer | |||
| 65 | - | } | |||
| 66 | - | ||||
| 67 | - | // newAudioTranscoder sets up an AAC (or other) decoder and a libopus encoder. | |||
| 68 | - | // inCodecPar is the AVCodecParameters* from the input audio stream. | |||
| 69 | - | // outFmtCtx is the AVFormatContext* for the output container (used to | |||
| 70 | - | // add the output stream). inStreamIdx is the input stream index. | |||
| 71 | - | func newAudioTranscoder(inCodecPar, outFmtCtx unsafe.Pointer, inStreamIdx int) (*audioTranscoder, error) { | |||
| 72 | - | return newAudioTranscoderImpl(inCodecPar, outFmtCtx, inStreamIdx) | |||
| 73 | - | } | |||
| 74 | - | ||||
| 75 | - | //nolint:gocyclo,funlen // FFmpeg decoder/encoder/resampler setup needs the necessary branches. | |||
| 76 | - | func newAudioTranscoderImpl(inCodecPar, outFmtCtx unsafe.Pointer, inStreamIdx int) (*audioTranscoder, error) { | |||
| 77 | - | // --- Decoder setup --- | |||
| 78 | - | codecID := ffmpeg.CodecParCodecID(inCodecPar) | |||
| 79 | - | dec := ffmpeg.CodecFindDecoder(codecID) | |||
| 80 | - | if dec == nil { | |||
| 81 | - | return nil, fmt.Errorf("audio decoder not found for codec ID %d", codecID) | |||
| 82 | - | } | |||
| 83 | - | ||||
| 84 | - | decCtx := ffmpeg.CodecAllocContext3(dec) | |||
| 85 | - | if decCtx == nil { | |||
| 86 | - | return nil, errors.New("failed to allocate audio decoder context") | |||
| 87 | - | } | |||
| 88 | - | ||||
| 89 | - | if ret := ffmpeg.CodecParametersToContext(decCtx, inCodecPar); ret < 0 { | |||
| 90 | - | freeCodecContext(&decCtx) | |||
| 91 | - | return nil, fmt.Errorf("failed to copy audio codec parameters to decoder: %d", ret) | |||
| 92 | - | } | |||
| 93 | - | ||||
| 94 | - | if ret := ffmpeg.CodecOpen2(decCtx, dec, nil); ret < 0 { | |||
| 95 | - | freeCodecContext(&decCtx) | |||
| 96 | - | return nil, fmt.Errorf("failed to open audio decoder: %d", ret) | |||
| 97 | - | } | |||
| 98 | - | ||||
| 99 | - | // --- Encoder setup (libopus) --- | |||
| 100 | - | enc := ffmpeg.CodecFindEncoderByName("libopus") | |||
| 101 | - | if enc == nil { | |||
| 102 | - | freeCodecContext(&decCtx) | |||
| 103 | - | return nil, errors.New("libopus encoder not found") | |||
| 104 | - | } | |||
| 105 | - | ||||
| 106 | - | encCtx := ffmpeg.CodecAllocContext3(enc) | |||
| 107 | - | if encCtx == nil { | |||
| 108 | - | freeCodecContext(&decCtx) | |||
| 109 | - | return nil, errors.New("failed to allocate audio encoder context") | |||
| 110 | - | } | |||
| 111 | - | ||||
| 112 | - | // libopus accepts s16 (interleaved) and flt (float interleaved). | |||
| 113 | - | // Planar formats (fltp) are NOT supported and cause encoder open to fail. | |||
| 114 | - | ffmpeg.CodecCtxSetSampleFmt(encCtx, int32(ffmpeg.SampleFmtFlt)) | |||
| 115 | - | ffmpeg.CodecCtxSetSampleRate(encCtx, opusSampleRate) | |||
| 116 | - | codecCtxSetBitRate(encCtx, opusBitrate) | |||
| 117 | - | ||||
| 118 | - | // Copy channel layout from decoder to encoder. | |||
| 119 | - | codecCtxCopyChLayout(encCtx, decCtx) | |||
| 120 | - | ||||
| 121 | - | // Set time base to 1/sample_rate for audio. | |||
| 122 | - | ffmpeg.CodecCtxSetTimeBase(encCtx, ffmpeg.AVRational{Num: 1, Den: opusSampleRate}) | |||
| 123 | - | ||||
| 124 | - | // Note: WebM container does not require AV_CODEC_FLAG_GLOBAL_HEADER for Opus. | |||
| 125 | - | ||||
| 126 | - | if ret := ffmpeg.CodecOpen2(encCtx, enc, nil); ret < 0 { | |||
| 127 | - | freeCodecContext(&encCtx) | |||
| 128 | - | freeCodecContext(&decCtx) | |||
| 129 | - | return nil, fmt.Errorf("failed to open audio encoder: %d", ret) | |||
| 130 | - | } | |||
| 131 | - | ||||
| 132 | - | // --- Add output stream --- | |||
| 133 | - | outStreamPtr := ffmpeg.FormatNewStream(outFmtCtx, nil) | |||
| 134 | - | if outStreamPtr == nil { | |||
| 135 | - | freeCodecContext(&encCtx) | |||
| 136 | - | freeCodecContext(&decCtx) | |||
| 137 | - | return nil, errors.New("failed to create output audio stream") | |||
| 138 | - | } | |||
| 139 | - | ||||
| 140 | - | outStreamWrap := ffmpeg.WrapStream(outStreamPtr) | |||
| 141 | - | outStreamIdx := int(outStreamWrap.Index()) | |||
| 142 | - | ||||
| 143 | - | // Copy encoder parameters to the output stream's codecpar. | |||
| 144 | - | if ret := ffmpeg.CodecParametersFromContext(outStreamWrap.Codecpar(), encCtx); ret < 0 { | |||
| 145 | - | freeCodecContext(&encCtx) | |||
| 146 | - | freeCodecContext(&decCtx) | |||
| 147 | - | return nil, fmt.Errorf("failed to copy audio encoder params to stream: %d", ret) | |||
| 148 | - | } | |||
| 149 | - | ||||
| 150 | - | // --- Resampler setup (if sample rates differ) --- | |||
| 151 | - | var swr unsafe.Pointer | |||
| 152 | - | var resampledFrame unsafe.Pointer | |||
| 153 | - | ||||
| 154 | - | inSampleRate := ffmpeg.CodecCtxSampleRate(decCtx) | |||
| 155 | - | inSampleFmt := ffmpeg.CodecCtxSampleFmt(decCtx) | |||
| 156 | - | needResample := inSampleRate != opusSampleRate || inSampleFmt != int32(ffmpeg.SampleFmtFlt) | |||
| 157 | - | ||||
| 158 | - | if needResample { | |||
| 159 | - | swr = ffmpeg.SwrAlloc() | |||
| 160 | - | if swr == nil { | |||
| 161 | - | freeCodecContext(&encCtx) | |||
| 162 | - | freeCodecContext(&decCtx) | |||
| 163 | - | return nil, errors.New("failed to allocate resampler") | |||
| 164 | - | } | |||
| 165 | - | ||||
| 166 | - | // Configure resampler via av_opt_set on the SwrContext. | |||
| 167 | - | ffmpeg.AVOptSetInt(swr, "in_sample_rate", int64(inSampleRate), 0) | |||
| 168 | - | ffmpeg.AVOptSetInt(swr, "out_sample_rate", int64(opusSampleRate), 0) | |||
| 169 | - | ffmpeg.AVOptSet(swr, "in_sample_fmt", ffmpeg.GetSampleFmtName(inSampleFmt), 0) | |||
| 170 | - | ffmpeg.AVOptSet(swr, "out_sample_fmt", ffmpeg.GetSampleFmtName(int32(ffmpeg.SampleFmtFlt)), 0) | |||
| 171 | - | ||||
| 172 | - | // Modern FFmpeg (8.x) requires explicit channel layout configuration | |||
| 173 | - | // on the SwrContext. Without it, swr_init may succeed but the resampler | |||
| 174 | - | // operates with 0 channels and produces silence. Copy the decoder's | |||
| 175 | - | // channel layout to both in and out (we keep them identical — same | |||
| 176 | - | // number of channels, only sample rate/format changes). | |||
| 177 | - | swrSetChLayoutFromCodecCtx(swr, decCtx, offsetSwrInChLayout) | |||
| 178 | - | swrSetChLayoutFromCodecCtx(swr, decCtx, offsetSwrOutChLayout) | |||
| 179 | - | ||||
| 180 | - | if ret := ffmpeg.SwrInit(swr); ret < 0 { | |||
| 181 | - | freeSwrContext(&swr) | |||
| 182 | - | freeCodecContext(&encCtx) | |||
| 183 | - | freeCodecContext(&decCtx) | |||
| 184 | - | return nil, fmt.Errorf("failed to init resampler: %d", ret) | |||
| 185 | - | } | |||
| 186 | - | ||||
| 187 | - | // Allocate a reusable output frame for resampled data. | |||
| 188 | - | resampledFrame = ffmpeg.FrameAlloc() | |||
| 189 | - | if resampledFrame == nil { | |||
| 190 | - | freeSwrContext(&swr) | |||
| 191 | - | freeCodecContext(&encCtx) | |||
| 192 | - | freeCodecContext(&decCtx) | |||
| 193 | - | return nil, errors.New("failed to allocate resampled frame") | |||
| 194 | - | } | |||
| 195 | - | ||||
| 196 | - | frameSetFormat(resampledFrame, int32(ffmpeg.SampleFmtFlt)) | |||
| 197 | - | frameSetSampleRate(resampledFrame, opusSampleRate) | |||
| 198 | - | frameSetNbSamples(resampledFrame, opusFrameSize) | |||
| 199 | - | // Copy channel layout from decoder to the resampled frame. | |||
| 200 | - | codecCtxCopyChLayout(resampledFrame, decCtx) | |||
| 201 | - | } | |||
| 202 | - | ||||
| 203 | - | decFrame := ffmpeg.FrameAlloc() | |||
| 204 | - | if decFrame == nil { | |||
| 205 | - | if resampledFrame != nil { | |||
| 206 | - | freeFrame(&resampledFrame) | |||
| 207 | - | } | |||
| 208 | - | if swr != nil { | |||
| 209 | - | freeSwrContext(&swr) | |||
| 210 | - | } | |||
| 211 | - | freeCodecContext(&encCtx) | |||
| 212 | - | freeCodecContext(&decCtx) | |||
| 213 | - | return nil, errors.New("failed to allocate audio decode frame") | |||
| 214 | - | } | |||
| 215 | - | ||||
| 216 | - | encPkt := ffmpeg.PacketAlloc() | |||
| 217 | - | if encPkt == nil { | |||
| 218 | - | freeFrame(&decFrame) | |||
| 219 | - | if resampledFrame != nil { | |||
| 220 | - | freeFrame(&resampledFrame) | |||
| 221 | - | } | |||
| 222 | - | if swr != nil { | |||
| 223 | - | freeSwrContext(&swr) | |||
| 224 | - | } | |||
| 225 | - | freeCodecContext(&encCtx) | |||
| 226 | - | freeCodecContext(&decCtx) | |||
| 227 | - | return nil, errors.New("failed to allocate audio encode packet") | |||
| 228 | - | } | |||
| 229 | - | ||||
| 230 | - | return &audioTranscoder{ | |||
| 231 | - | decoder: decCtx, | |||
| 232 | - | encoder: encCtx, | |||
| 233 | - | resampler: swr, | |||
| 234 | - | frame: resampledFrame, | |||
| 235 | - | decFrame: decFrame, | |||
| 236 | - | encPkt: encPkt, | |||
| 237 | - | inStream: inStreamIdx, | |||
| 238 | - | outStream: outStreamIdx, | |||
| 239 | - | }, nil | |||
| 240 | - | } | |||
| 241 | - | ||||
| 242 | - | // processPacket decodes an audio packet, optionally resamples, encodes | |||
| 243 | - | // to Opus, and writes the resulting packets to outFmtCtx. | |||
| 244 | - | func (a *audioTranscoder) processPacket(pkt, outFmtCtx unsafe.Pointer) error { | |||
| 245 | - | // Send packet to decoder. | |||
| 246 | - | sendRet := ffmpeg.CodecSendPacket(a.decoder, pkt) | |||
| 247 | - | if sendRet < 0 { | |||
| 248 | - | return fmt.Errorf("audio decode send packet: %d", sendRet) | |||
| 249 | - | } | |||
| 250 | - | ||||
| 251 | - | frame := a.decFrame | |||
| 252 | - | encPkt := a.encPkt | |||
| 253 | - | ||||
| 254 | - | for { | |||
| 255 | - | recvRet := ffmpeg.CodecReceiveFrame(a.decoder, frame) | |||
| 256 | - | if recvRet < 0 { | |||
| 257 | - | // EAGAIN or EOF — no more frames from this packet. | |||
| 258 | - | break | |||
| 259 | - | } | |||
| 260 | - | ||||
| 261 | - | // Determine which frame to send to the encoder. | |||
| 262 | - | encFrame := frame | |||
| 263 | - | if a.resampler != nil { | |||
| 264 | - | encFrame = a.frame | |||
| 265 | - | ||||
| 266 | - | inSamples := frameNbSamples(frame) | |||
| 267 | - | ||||
| 268 | - | // Allocate buffer for output frame if needed. | |||
| 269 | - | frameSetNbSamples(a.frame, opusFrameSize) | |||
| 270 | - | if bufferRet := ffmpeg.FrameGetBuffer(a.frame, 0); bufferRet < 0 { | |||
| 271 | - | ffmpeg.FrameUnref(frame) | |||
| 272 | - | return fmt.Errorf("failed to allocate resampled frame buffer: %d", bufferRet) | |||
| 273 | - | } | |||
| 274 | - | ||||
| 275 | - | converted := swrConvert(a.resampler, a.frame, frame, opusFrameSize, inSamples) | |||
| 276 | - | if converted < 0 { | |||
| 277 | - | ffmpeg.FrameUnref(frame) | |||
| 278 | - | return fmt.Errorf("audio resample failed: %d", converted) | |||
| 279 | - | } | |||
| 280 | - | frameSetNbSamples(a.frame, converted) | |||
| 281 | - | ||||
| 282 | - | // Copy pts from decoded frame, rescaling for the new sample rate. | |||
| 283 | - | pts := framePts(frame) | |||
| 284 | - | if pts >= 0 { | |||
| 285 | - | decTB := ffmpeg.CodecCtxTimeBase(a.decoder) | |||
| 286 | - | encTB := ffmpeg.CodecCtxTimeBase(a.encoder) | |||
| 287 | - | frameSetPts(a.frame, ffmpeg.RescaleQ(pts, decTB, encTB)) | |||
| 288 | - | } | |||
| 289 | - | } | |||
| 290 | - | ||||
| 291 | - | // Send frame to encoder. | |||
| 292 | - | if sendFrameRet := ffmpeg.CodecSendFrame(a.encoder, encFrame); sendFrameRet < 0 { | |||
| 293 | - | ffmpeg.FrameUnref(frame) | |||
| 294 | - | return fmt.Errorf("audio encode send frame: %d", sendFrameRet) | |||
| 295 | - | } | |||
| 296 | - | ||||
| 297 | - | // Receive encoded packets. | |||
| 298 | - | for { | |||
| 299 | - | recvPktRet := ffmpeg.CodecReceivePacket(a.encoder, encPkt) | |||
| 300 | - | if recvPktRet < 0 { | |||
| 301 | - | break | |||
| 302 | - | } | |||
| 303 | - | ||||
| 304 | - | pktSetStreamIndex(encPkt, int32(a.outStream)) | |||
| 305 | - | ||||
| 306 | - | // Rescale timestamps from encoder time base to output stream time base. | |||
| 307 | - | outStream := ffmpeg.FmtCtxStream(outFmtCtx, a.outStream) | |||
| 308 | - | outStreamWrap := ffmpeg.WrapStream(outStream) | |||
| 309 | - | ffmpeg.PacketRescaleTs(encPkt, ffmpeg.CodecCtxTimeBase(a.encoder), outStreamWrap.TimeBase()) | |||
| 310 | - | ||||
| 311 | - | if wret := ffmpeg.InterleavedWriteFrame(outFmtCtx, encPkt); wret < 0 { | |||
| 312 | - | ffmpeg.PacketUnref(encPkt) | |||
| 313 | - | ffmpeg.FrameUnref(frame) | |||
| 314 | - | return fmt.Errorf("audio write frame: %d", wret) | |||
| 315 | - | } | |||
| 316 | - | ffmpeg.PacketUnref(encPkt) | |||
| 317 | - | } | |||
| 318 | - | ||||
| 319 | - | ffmpeg.FrameUnref(frame) | |||
| 320 | - | } | |||
| 321 | - | ||||
| 322 | - | return nil | |||
| 323 | - | } | |||
| 324 | - | ||||
| 325 | - | // flush drains remaining frames from the decoder and encoder. | |||
| 326 | - | // The logger parameter is used to report non-fatal resampling errors | |||
| 327 | - | // that occur during flush (we continue flushing rather than aborting). | |||
| 328 | - | func (a *audioTranscoder) flush(outFmtCtx unsafe.Pointer, logger zerolog.Logger) { | |||
| 329 | - | // Flush decoder by sending NULL packet. | |||
| 330 | - | ffmpeg.CodecSendPacket(a.decoder, nil) | |||
| 331 | - | ||||
| 332 | - | frame := a.decFrame | |||
| 333 | - | encPkt := a.encPkt | |||
| 334 | - | ||||
| 335 | - | // Drain decoded frames. | |||
| 336 | - | for { | |||
| 337 | - | recvRet := ffmpeg.CodecReceiveFrame(a.decoder, frame) | |||
| 338 | - | if recvRet < 0 { | |||
| 339 | - | break | |||
| 340 | - | } | |||
| 341 | - | ||||
| 342 | - | encFrame := frame | |||
| 343 | - | if a.resampler != nil { | |||
| 344 | - | encFrame = a.frame | |||
| 345 | - | inSamples := frameNbSamples(frame) | |||
| 346 | - | ||||
| 347 | - | frameSetNbSamples(a.frame, opusFrameSize) | |||
| 348 | - | if bufferRet := ffmpeg.FrameGetBuffer(a.frame, 0); bufferRet < 0 { | |||
| 349 | - | logger.Warn().Int32("ret", bufferRet).Msg("failed to allocate resampled frame buffer during flush") | |||
| 350 | - | ffmpeg.FrameUnref(frame) | |||
| 351 | - | continue | |||
| 352 | - | } | |||
| 353 | - | ||||
| 354 | - | converted := swrConvert(a.resampler, a.frame, frame, opusFrameSize, inSamples) | |||
| 355 | - | if converted < 0 { | |||
| 356 | - | logger.Warn().Int32("ret", converted).Msg("audio resample failed during flush") | |||
| 357 | - | ffmpeg.FrameUnref(frame) | |||
| 358 | - | continue | |||
| 359 | - | } | |||
| 360 | - | frameSetNbSamples(a.frame, converted) | |||
| 361 | - | } | |||
| 362 | - | ||||
| 363 | - | ffmpeg.CodecSendFrame(a.encoder, encFrame) | |||
| 364 | - | ||||
| 365 | - | for { | |||
| 366 | - | recvPktRet := ffmpeg.CodecReceivePacket(a.encoder, encPkt) | |||
| 367 | - | if recvPktRet < 0 { | |||
| 368 | - | break | |||
| 369 | - | } | |||
| 370 | - | pktSetStreamIndex(encPkt, int32(a.outStream)) | |||
| 371 | - | outStream := ffmpeg.FmtCtxStream(outFmtCtx, a.outStream) | |||
| 372 | - | outStreamWrap := ffmpeg.WrapStream(outStream) | |||
| 373 | - | ffmpeg.PacketRescaleTs(encPkt, ffmpeg.CodecCtxTimeBase(a.encoder), outStreamWrap.TimeBase()) | |||
| 374 | - | ffmpeg.InterleavedWriteFrame(outFmtCtx, encPkt) | |||
| 375 | - | ffmpeg.PacketUnref(encPkt) | |||
| 376 | - | } | |||
| 377 | - | ||||
| 378 | - | ffmpeg.FrameUnref(frame) | |||
| 379 | - | } | |||
| 380 | - | ||||
| 381 | - | // Flush encoder by sending NULL frame. | |||
| 382 | - | ffmpeg.CodecSendFrame(a.encoder, nil) | |||
| 383 | - | for { | |||
| 384 | - | flushPktRet := ffmpeg.CodecReceivePacket(a.encoder, encPkt) | |||
| 385 | - | if flushPktRet < 0 { | |||
| 386 | - | break | |||
| 387 | - | } | |||
| 388 | - | pktSetStreamIndex(encPkt, int32(a.outStream)) | |||
| 389 | - | outStream := ffmpeg.FmtCtxStream(outFmtCtx, a.outStream) | |||
| 390 | - | outStreamWrap := ffmpeg.WrapStream(outStream) | |||
| 391 | - | ffmpeg.PacketRescaleTs(encPkt, ffmpeg.CodecCtxTimeBase(a.encoder), outStreamWrap.TimeBase()) | |||
| 392 | - | ffmpeg.InterleavedWriteFrame(outFmtCtx, encPkt) | |||
| 393 | - | ffmpeg.PacketUnref(encPkt) | |||
| 394 | - | } | |||
| 395 | - | } | |||
| 396 | - | ||||
| 397 | - | // close frees all FFmpeg resources owned by the audio transcoder. | |||
| 398 | - | func (a *audioTranscoder) close() { | |||
| 399 | - | if a.encPkt != nil { | |||
| 400 | - | freePacket(&a.encPkt) | |||
| 401 | - | } | |||
| 402 | - | if a.decFrame != nil { | |||
| 403 | - | freeFrame(&a.decFrame) | |||
| 404 | - | } | |||
| 405 | - | if a.frame != nil { | |||
| 406 | - | freeFrame(&a.frame) | |||
| 407 | - | } | |||
| 408 | - | if a.resampler != nil { | |||
| 409 | - | freeSwrContext(&a.resampler) | |||
| 410 | - | } | |||
| 411 | - | if a.encoder != nil { | |||
| 412 | - | freeCodecContext(&a.encoder) | |||
| 413 | - | } | |||
| 414 | - | if a.decoder != nil { | |||
| 415 | - | freeCodecContext(&a.decoder) | |||
| 416 | - | } | |||
| 417 | - | } | |||
D internal/infrastructure/transcoder/detect.go +0 -180
| @@ -1,180 +0,0 @@ | |||
| 1 | - | package transcoder | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "mime" | |
| 5 | - | "net/url" | |
| 6 | - | "path" | |
| 7 | - | "strings" | |
| 8 | - | ) | |
| 9 | - | ||
| 10 | - | // proprietaryVideoMIMEs lists MIME types for containers that typically carry | |
| 11 | - | // proprietary codecs (H.264, HEVC, AAC). These require GPU transcoding to | |
| 12 | - | // open formats (VP8/VP9/AV1 + Opus) for playback in browsers without | |
| 13 | - | // proprietary codec support. | |
| 14 | - | var proprietaryVideoMIMEs = map[string]bool{ | |
| 15 | - | "video/mp4": true, // H.264/HEVC + AAC | |
| 16 | - | "video/x-flv": true, // Flash Video (H.264 or Sorenson) | |
| 17 | - | "video/3gpp": true, // 3GPP (H.264 + AAC) | |
| 18 | - | "video/3gpp2": true, // 3GPP2 (H.264 + AAC) | |
| 19 | - | "video/mp2t": true, // MPEG-TS (usually H.264) | |
| 20 | - | "video/mpeg": true, // MPEG-1/2 | |
| 21 | - | "video/x-msvideo": true, // AVI (often H.264) | |
| 22 | - | "video/x-matroska": true, // MKV (often H.264/HEVC) | |
| 23 | - | "video/quicktime": true, // MOV (H.264 + AAC) | |
| 24 | - | "video/x-m4v": true, // M4V (H.264 + AAC) | |
| 25 | - | } | |
| 26 | - | ||
| 27 | - | // openVideoMIMEs lists MIME types for containers carrying open codecs | |
| 28 | - | // (VP8/VP9/AV1 + Opus/Vorbis). These do not require transcoding. | |
| 29 | - | var openVideoMIMEs = map[string]bool{ | |
| 30 | - | "video/webm": true, // VP8/VP9/AV1 + Opus/Vorbis | |
| 31 | - | "video/ogg": true, // Theora + Vorbis | |
| 32 | - | } | |
| 33 | - | ||
| 34 | - | // streamingManifestMIMEs lists manifest/container MIME types that usually | |
| 35 | - | // resolve to proprietary segment streams via HLS or DASH. | |
| 36 | - | var streamingManifestMIMEs = map[string]bool{ | |
| 37 | - | "application/vnd.apple.mpegurl": true, // RFC 8216 | |
| 38 | - | "application/x-mpegurl": true, // common non-standard alias | |
| 39 | - | "audio/x-mpegurl": true, // Go stdlib / legacy servers | |
| 40 | - | "audio/mpegurl": true, // rare legacy alias | |
| 41 | - | "application/dash+xml": true, | |
| 42 | - | } | |
| 43 | - | ||
| 44 | - | var proprietaryVideoExtensions = map[string]bool{ | |
| 45 | - | ".mp4": true, | |
| 46 | - | ".m4v": true, | |
| 47 | - | ".mov": true, | |
| 48 | - | ".mkv": true, | |
| 49 | - | ".avi": true, | |
| 50 | - | ".flv": true, | |
| 51 | - | ".3gp": true, | |
| 52 | - | ".3g2": true, | |
| 53 | - | ".mpeg": true, | |
| 54 | - | ".mpg": true, | |
| 55 | - | } | |
| 56 | - | ||
| 57 | - | const syntheticTranscodePath = "/__dumber__/transcode.webm" | |
| 58 | - | ||
| 59 | - | // IsProprietaryVideoMIME returns true if the MIME type indicates a video | |
| 60 | - | // format that typically carries proprietary codecs requiring transcoding. | |
| 61 | - | func IsProprietaryVideoMIME(contentType string) bool { | |
| 62 | - | mediaType := parseMediaType(contentType) | |
| 63 | - | return proprietaryVideoMIMEs[mediaType] | |
| 64 | - | } | |
| 65 | - | ||
| 66 | - | // IsOpenVideoMIME returns true if the MIME type indicates a video format | |
| 67 | - | // with open codecs that do not require transcoding. | |
| 68 | - | func IsOpenVideoMIME(contentType string) bool { | |
| 69 | - | mediaType := parseMediaType(contentType) | |
| 70 | - | return openVideoMIMEs[mediaType] | |
| 71 | - | } | |
| 72 | - | ||
| 73 | - | // IsStreamingManifestMIME returns true for HLS/DASH manifest MIME types. | |
| 74 | - | func IsStreamingManifestMIME(contentType string) bool { | |
| 75 | - | mediaType := parseMediaType(contentType) | |
| 76 | - | return streamingManifestMIMEs[mediaType] | |
| 77 | - | } | |
| 78 | - | ||
| 79 | - | // IsStreamingManifestURL returns true for URLs whose path indicates an HLS or | |
| 80 | - | // DASH manifest entrypoint. | |
| 81 | - | func IsStreamingManifestURL(rawURL string) bool { | |
| 82 | - | if rawURL == "" { | |
| 83 | - | return false | |
| 84 | - | } | |
| 85 | - | ||
| 86 | - | parsed, err := url.Parse(rawURL) | |
| 87 | - | if err != nil { | |
| 88 | - | return hasManifestExtension(rawURL) | |
| 89 | - | } | |
| 90 | - | ||
| 91 | - | return hasManifestExtension(parsed.Path) | |
| 92 | - | } | |
| 93 | - | ||
| 94 | - | // IsEagerTranscodeURL returns true for URLs that can be identified as | |
| 95 | - | // transcodable before any response MIME is received. | |
| 96 | - | func IsEagerTranscodeURL(rawURL string) bool { | |
| 97 | - | if rawURL == "" { | |
| 98 | - | return false | |
| 99 | - | } | |
| 100 | - | ||
| 101 | - | if IsSyntheticTranscodeURL(rawURL) { | |
| 102 | - | return true | |
| 103 | - | } | |
| 104 | - | ||
| 105 | - | parsed, err := url.Parse(rawURL) | |
| 106 | - | if err != nil { | |
| 107 | - | return hasEagerTranscodeExtension(rawURL) | |
| 108 | - | } | |
| 109 | - | ||
| 110 | - | return hasEagerTranscodeExtension(parsed.Path) | |
| 111 | - | } | |
| 112 | - | ||
| 113 | - | // IsSyntheticTranscodeURL returns true for same-origin synthetic media URLs | |
| 114 | - | // that should be intercepted and resolved to an underlying source URL. | |
| 115 | - | func IsSyntheticTranscodeURL(rawURL string) bool { | |
| 116 | - | sourceURL, referer, origin, ok := ParseSyntheticTranscodeURL(rawURL) | |
| 117 | - | _ = sourceURL | |
| 118 | - | _ = referer | |
| 119 | - | _ = origin | |
| 120 | - | return ok | |
| 121 | - | } | |
| 122 | - | ||
| 123 | - | // ParseSyntheticTranscodeURL extracts the underlying source URL and optional | |
| 124 | - | // forwarded headers from a synthetic transcode URL. | |
| 125 | - | func ParseSyntheticTranscodeURL(rawURL string) (sourceURL, referer, origin string, ok bool) { | |
| 126 | - | if rawURL == "" { | |
| 127 | - | return "", "", "", false | |
| 128 | - | } | |
| 129 | - | ||
| 130 | - | parsed, err := url.Parse(rawURL) | |
| 131 | - | if err != nil { | |
| 132 | - | return "", "", "", false | |
| 133 | - | } | |
| 134 | - | if parsed.Path != syntheticTranscodePath { | |
| 135 | - | return "", "", "", false | |
| 136 | - | } | |
| 137 | - | ||
| 138 | - | sourceURL = strings.TrimSpace(parsed.Query().Get("src")) | |
| 139 | - | if sourceURL == "" { | |
| 140 | - | return "", "", "", false | |
| 141 | - | } | |
| 142 | - | sourceParsed, err := url.Parse(sourceURL) | |
| 143 | - | if err != nil || sourceParsed.Host == "" || (sourceParsed.Scheme != "http" && sourceParsed.Scheme != "https") { | |
| 144 | - | return "", "", "", false | |
| 145 | - | } | |
| 146 | - | ||
| 147 | - | referer = strings.TrimSpace(parsed.Query().Get("referer")) | |
| 148 | - | origin = strings.TrimSpace(parsed.Query().Get("origin")) | |
| 149 | - | return sourceURL, referer, origin, true | |
| 150 | - | } | |
| 151 | - | ||
| 152 | - | func hasManifestExtension(value string) bool { | |
| 153 | - | ext := strings.ToLower(path.Ext(value)) | |
| 154 | - | return ext == ".m3u8" || ext == ".mpd" | |
| 155 | - | } | |
| 156 | - | ||
| 157 | - | func hasEagerTranscodeExtension(value string) bool { | |
| 158 | - | ext := strings.ToLower(path.Ext(value)) | |
| 159 | - | return hasManifestExtension(value) || proprietaryVideoExtensions[ext] | |
| 160 | - | } | |
| 161 | - | ||
| 162 | - | // parseMediaType extracts the MIME type from a Content-Type header value, | |
| 163 | - | // stripping parameters like charset or codecs. | |
| 164 | - | func parseMediaType(contentType string) string { | |
| 165 | - | if contentType == "" { | |
| 166 | - | return "" | |
| 167 | - | } | |
| 168 | - | // mime.ParseMediaType handles the full RFC 2045 parsing, but is | |
| 169 | - | // overkill and can fail on non-conforming values. A simple split | |
| 170 | - | // on ";" is more resilient for our needs. | |
| 171 | - | mediaType, _, _ := mime.ParseMediaType(contentType) | |
| 172 | - | if mediaType != "" { | |
| 173 | - | return strings.ToLower(mediaType) | |
| 174 | - | } | |
| 175 | - | // Fallback: trim parameters manually | |
| 176 | - | if idx := strings.IndexByte(contentType, ';'); idx >= 0 { | |
| 177 | - | contentType = contentType[:idx] | |
| 178 | - | } | |
| 179 | - | return strings.ToLower(strings.TrimSpace(contentType)) | |
| 180 | - | } | |
| @@ -1,180 +0,0 @@ | |||||
| 1 | - | package transcoder | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "mime" | |||
| 5 | - | "net/url" | |||
| 6 | - | "path" | |||
| 7 | - | "strings" | |||
| 8 | - | ) | |||
| 9 | - | ||||
| 10 | - | // proprietaryVideoMIMEs lists MIME types for containers that typically carry | |||
| 11 | - | // proprietary codecs (H.264, HEVC, AAC). These require GPU transcoding to | |||
| 12 | - | // open formats (VP8/VP9/AV1 + Opus) for playback in browsers without | |||
| 13 | - | // proprietary codec support. | |||
| 14 | - | var proprietaryVideoMIMEs = map[string]bool{ | |||
| 15 | - | "video/mp4": true, // H.264/HEVC + AAC | |||
| 16 | - | "video/x-flv": true, // Flash Video (H.264 or Sorenson) | |||
| 17 | - | "video/3gpp": true, // 3GPP (H.264 + AAC) | |||
| 18 | - | "video/3gpp2": true, // 3GPP2 (H.264 + AAC) | |||
| 19 | - | "video/mp2t": true, // MPEG-TS (usually H.264) | |||
| 20 | - | "video/mpeg": true, // MPEG-1/2 | |||
| 21 | - | "video/x-msvideo": true, // AVI (often H.264) | |||
| 22 | - | "video/x-matroska": true, // MKV (often H.264/HEVC) | |||
| 23 | - | "video/quicktime": true, // MOV (H.264 + AAC) | |||
| 24 | - | "video/x-m4v": true, // M4V (H.264 + AAC) | |||
| 25 | - | } | |||
| 26 | - | ||||
| 27 | - | // openVideoMIMEs lists MIME types for containers carrying open codecs | |||
| 28 | - | // (VP8/VP9/AV1 + Opus/Vorbis). These do not require transcoding. | |||
| 29 | - | var openVideoMIMEs = map[string]bool{ | |||
| 30 | - | "video/webm": true, // VP8/VP9/AV1 + Opus/Vorbis | |||
| 31 | - | "video/ogg": true, // Theora + Vorbis | |||
| 32 | - | } | |||
| 33 | - | ||||
| 34 | - | // streamingManifestMIMEs lists manifest/container MIME types that usually | |||
| 35 | - | // resolve to proprietary segment streams via HLS or DASH. | |||
| 36 | - | var streamingManifestMIMEs = map[string]bool{ | |||
| 37 | - | "application/vnd.apple.mpegurl": true, // RFC 8216 | |||
| 38 | - | "application/x-mpegurl": true, // common non-standard alias | |||
| 39 | - | "audio/x-mpegurl": true, // Go stdlib / legacy servers | |||
| 40 | - | "audio/mpegurl": true, // rare legacy alias | |||
| 41 | - | "application/dash+xml": true, | |||
| 42 | - | } | |||
| 43 | - | ||||
| 44 | - | var proprietaryVideoExtensions = map[string]bool{ | |||
| 45 | - | ".mp4": true, | |||
| 46 | - | ".m4v": true, | |||
| 47 | - | ".mov": true, | |||
| 48 | - | ".mkv": true, | |||
| 49 | - | ".avi": true, | |||
| 50 | - | ".flv": true, | |||
| 51 | - | ".3gp": true, | |||
| 52 | - | ".3g2": true, | |||
| 53 | - | ".mpeg": true, | |||
| 54 | - | ".mpg": true, | |||
| 55 | - | } | |||
| 56 | - | ||||
| 57 | - | const syntheticTranscodePath = "/__dumber__/transcode.webm" | |||
| 58 | - | ||||
| 59 | - | // IsProprietaryVideoMIME returns true if the MIME type indicates a video | |||
| 60 | - | // format that typically carries proprietary codecs requiring transcoding. | |||
| 61 | - | func IsProprietaryVideoMIME(contentType string) bool { | |||
| 62 | - | mediaType := parseMediaType(contentType) | |||
| 63 | - | return proprietaryVideoMIMEs[mediaType] | |||
| 64 | - | } | |||
| 65 | - | ||||
| 66 | - | // IsOpenVideoMIME returns true if the MIME type indicates a video format | |||
| 67 | - | // with open codecs that do not require transcoding. | |||
| 68 | - | func IsOpenVideoMIME(contentType string) bool { | |||
| 69 | - | mediaType := parseMediaType(contentType) | |||
| 70 | - | return openVideoMIMEs[mediaType] | |||
| 71 | - | } | |||
| 72 | - | ||||
| 73 | - | // IsStreamingManifestMIME returns true for HLS/DASH manifest MIME types. | |||
| 74 | - | func IsStreamingManifestMIME(contentType string) bool { | |||
| 75 | - | mediaType := parseMediaType(contentType) | |||
| 76 | - | return streamingManifestMIMEs[mediaType] | |||
| 77 | - | } | |||
| 78 | - | ||||
| 79 | - | // IsStreamingManifestURL returns true for URLs whose path indicates an HLS or | |||
| 80 | - | // DASH manifest entrypoint. | |||
| 81 | - | func IsStreamingManifestURL(rawURL string) bool { | |||
| 82 | - | if rawURL == "" { | |||
| 83 | - | return false | |||
| 84 | - | } | |||
| 85 | - | ||||
| 86 | - | parsed, err := url.Parse(rawURL) | |||
| 87 | - | if err != nil { | |||
| 88 | - | return hasManifestExtension(rawURL) | |||
| 89 | - | } | |||
| 90 | - | ||||
| 91 | - | return hasManifestExtension(parsed.Path) | |||
| 92 | - | } | |||
| 93 | - | ||||
| 94 | - | // IsEagerTranscodeURL returns true for URLs that can be identified as | |||
| 95 | - | // transcodable before any response MIME is received. | |||
| 96 | - | func IsEagerTranscodeURL(rawURL string) bool { | |||
| 97 | - | if rawURL == "" { | |||
| 98 | - | return false | |||
| 99 | - | } | |||
| 100 | - | ||||
| 101 | - | if IsSyntheticTranscodeURL(rawURL) { | |||
| 102 | - | return true | |||
| 103 | - | } | |||
| 104 | - | ||||
| 105 | - | parsed, err := url.Parse(rawURL) | |||
| 106 | - | if err != nil { | |||
| 107 | - | return hasEagerTranscodeExtension(rawURL) | |||
| 108 | - | } | |||
| 109 | - | ||||
| 110 | - | return hasEagerTranscodeExtension(parsed.Path) | |||
| 111 | - | } | |||
| 112 | - | ||||
| 113 | - | // IsSyntheticTranscodeURL returns true for same-origin synthetic media URLs | |||
| 114 | - | // that should be intercepted and resolved to an underlying source URL. | |||
| 115 | - | func IsSyntheticTranscodeURL(rawURL string) bool { | |||
| 116 | - | sourceURL, referer, origin, ok := ParseSyntheticTranscodeURL(rawURL) | |||
| 117 | - | _ = sourceURL | |||
| 118 | - | _ = referer | |||
| 119 | - | _ = origin | |||
| 120 | - | return ok | |||
| 121 | - | } | |||
| 122 | - | ||||
| 123 | - | // ParseSyntheticTranscodeURL extracts the underlying source URL and optional | |||
| 124 | - | // forwarded headers from a synthetic transcode URL. | |||
| 125 | - | func ParseSyntheticTranscodeURL(rawURL string) (sourceURL, referer, origin string, ok bool) { | |||
| 126 | - | if rawURL == "" { | |||
| 127 | - | return "", "", "", false | |||
| 128 | - | } | |||
| 129 | - | ||||
| 130 | - | parsed, err := url.Parse(rawURL) | |||
| 131 | - | if err != nil { | |||
| 132 | - | return "", "", "", false | |||
| 133 | - | } | |||
| 134 | - | if parsed.Path != syntheticTranscodePath { | |||
| 135 | - | return "", "", "", false | |||
| 136 | - | } | |||
| 137 | - | ||||
| 138 | - | sourceURL = strings.TrimSpace(parsed.Query().Get("src")) | |||
| 139 | - | if sourceURL == "" { | |||
| 140 | - | return "", "", "", false | |||
| 141 | - | } | |||
| 142 | - | sourceParsed, err := url.Parse(sourceURL) | |||
| 143 | - | if err != nil || sourceParsed.Host == "" || (sourceParsed.Scheme != "http" && sourceParsed.Scheme != "https") { | |||
| 144 | - | return "", "", "", false | |||
| 145 | - | } | |||
| 146 | - | ||||
| 147 | - | referer = strings.TrimSpace(parsed.Query().Get("referer")) | |||
| 148 | - | origin = strings.TrimSpace(parsed.Query().Get("origin")) | |||
| 149 | - | return sourceURL, referer, origin, true | |||
| 150 | - | } | |||
| 151 | - | ||||
| 152 | - | func hasManifestExtension(value string) bool { | |||
| 153 | - | ext := strings.ToLower(path.Ext(value)) | |||
| 154 | - | return ext == ".m3u8" || ext == ".mpd" | |||
| 155 | - | } | |||
| 156 | - | ||||
| 157 | - | func hasEagerTranscodeExtension(value string) bool { | |||
| 158 | - | ext := strings.ToLower(path.Ext(value)) | |||
| 159 | - | return hasManifestExtension(value) || proprietaryVideoExtensions[ext] | |||
| 160 | - | } | |||
| 161 | - | ||||
| 162 | - | // parseMediaType extracts the MIME type from a Content-Type header value, | |||
| 163 | - | // stripping parameters like charset or codecs. | |||
| 164 | - | func parseMediaType(contentType string) string { | |||
| 165 | - | if contentType == "" { | |||
| 166 | - | return "" | |||
| 167 | - | } | |||
| 168 | - | // mime.ParseMediaType handles the full RFC 2045 parsing, but is | |||
| 169 | - | // overkill and can fail on non-conforming values. A simple split | |||
| 170 | - | // on ";" is more resilient for our needs. | |||
| 171 | - | mediaType, _, _ := mime.ParseMediaType(contentType) | |||
| 172 | - | if mediaType != "" { | |||
| 173 | - | return strings.ToLower(mediaType) | |||
| 174 | - | } | |||
| 175 | - | // Fallback: trim parameters manually | |||
| 176 | - | if idx := strings.IndexByte(contentType, ';'); idx >= 0 { | |||
| 177 | - | contentType = contentType[:idx] | |||
| 178 | - | } | |||
| 179 | - | return strings.ToLower(strings.TrimSpace(contentType)) | |||
| 180 | - | } | |||
D internal/infrastructure/transcoder/detect_test.go +0 -254
| @@ -1,254 +0,0 @@ | |||
| 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 | - | } | |
| 87 | - | ||
| 88 | - | func TestIsStreamingManifestMIME(t *testing.T) { | |
| 89 | - | tests := []struct { | |
| 90 | - | name string | |
| 91 | - | contentType string | |
| 92 | - | want bool | |
| 93 | - | }{ | |
| 94 | - | {name: "hls apple", contentType: "application/vnd.apple.mpegURL", want: true}, | |
| 95 | - | {name: "hls x-mpegurl", contentType: "application/x-mpegURL", want: true}, | |
| 96 | - | {name: "hls audio x-mpegurl", contentType: "audio/x-mpegurl", want: true}, | |
| 97 | - | {name: "hls audio mpegurl", contentType: "audio/mpegurl", want: true}, | |
| 98 | - | {name: "dash", contentType: "application/dash+xml", want: true}, | |
| 99 | - | {name: "dash with charset", contentType: "application/dash+xml; charset=utf-8", want: true}, | |
| 100 | - | {name: "mp4", contentType: "video/mp4", want: false}, | |
| 101 | - | {name: "json", contentType: "application/json", want: false}, | |
| 102 | - | {name: "empty", contentType: "", want: false}, | |
| 103 | - | } | |
| 104 | - | ||
| 105 | - | for _, tt := range tests { | |
| 106 | - | t.Run(tt.name, func(t *testing.T) { | |
| 107 | - | got := IsStreamingManifestMIME(tt.contentType) | |
| 108 | - | if got != tt.want { | |
| 109 | - | t.Errorf("IsStreamingManifestMIME(%q) = %v, want %v", tt.contentType, got, tt.want) | |
| 110 | - | } | |
| 111 | - | }) | |
| 112 | - | } | |
| 113 | - | } | |
| 114 | - | ||
| 115 | - | func TestIsStreamingManifestURL(t *testing.T) { | |
| 116 | - | tests := []struct { | |
| 117 | - | name string | |
| 118 | - | rawURL string | |
| 119 | - | want bool | |
| 120 | - | }{ | |
| 121 | - | {name: "hls manifest", rawURL: "https://v.redd.it/abc/HLSPlaylist.m3u8?f=sd&v=1", want: true}, | |
| 122 | - | {name: "dash manifest", rawURL: "https://example.com/video/manifest.mpd", want: true}, | |
| 123 | - | {name: "uppercase extension", rawURL: "https://example.com/PLAYLIST.M3U8", want: true}, | |
| 124 | - | {name: "segment ts", rawURL: "https://example.com/chunk.ts", want: false}, | |
| 125 | - | {name: "plain mp4", rawURL: "https://example.com/video.mp4", want: false}, | |
| 126 | - | {name: "empty", rawURL: "", want: false}, | |
| 127 | - | {name: "invalid but obvious", rawURL: "not a url but playlist.m3u8", want: true}, | |
| 128 | - | } | |
| 129 | - | ||
| 130 | - | for _, tt := range tests { | |
| 131 | - | t.Run(tt.name, func(t *testing.T) { | |
| 132 | - | got := IsStreamingManifestURL(tt.rawURL) | |
| 133 | - | if got != tt.want { | |
| 134 | - | t.Errorf("IsStreamingManifestURL(%q) = %v, want %v", tt.rawURL, got, tt.want) | |
| 135 | - | } | |
| 136 | - | }) | |
| 137 | - | } | |
| 138 | - | } | |
| 139 | - | ||
| 140 | - | func TestIsEagerTranscodeURL(t *testing.T) { | |
| 141 | - | tests := []struct { | |
| 142 | - | name string | |
| 143 | - | rawURL string | |
| 144 | - | want bool | |
| 145 | - | }{ | |
| 146 | - | {name: "hls manifest", rawURL: "https://v.redd.it/abc/HLSPlaylist.m3u8?f=sd&v=1", want: true}, | |
| 147 | - | {name: "dash manifest", rawURL: "https://example.com/video/manifest.mpd", want: true}, | |
| 148 | - | {name: "mp4 file", rawURL: "https://example.com/video.mp4", want: true}, | |
| 149 | - | {name: "mov file", rawURL: "https://example.com/video.mov", want: true}, | |
| 150 | - | {name: "mkv uppercase", rawURL: "https://example.com/VIDEO.MKV", want: true}, | |
| 151 | - | {name: "hls segment", rawURL: "https://example.com/chunk.ts", want: false}, | |
| 152 | - | {name: "webm open codec", rawURL: "https://example.com/video.webm", want: false}, | |
| 153 | - | {name: "synthetic transcode", rawURL: "https://www.reddit.com/__dumber__/transcode.webm?src=https%3A%2F%2Fv.redd.it%2Fabc%2FHLSPlaylist.m3u8", want: true}, | |
| 154 | - | {name: "empty", rawURL: "", want: false}, | |
| 155 | - | } | |
| 156 | - | ||
| 157 | - | for _, tt := range tests { | |
| 158 | - | t.Run(tt.name, func(t *testing.T) { | |
| 159 | - | got := IsEagerTranscodeURL(tt.rawURL) | |
| 160 | - | if got != tt.want { | |
| 161 | - | t.Errorf("IsEagerTranscodeURL(%q) = %v, want %v", tt.rawURL, got, tt.want) | |
| 162 | - | } | |
| 163 | - | }) | |
| 164 | - | } | |
| 165 | - | } | |
| 166 | - | ||
| 167 | - | func TestParseSyntheticTranscodeURL(t *testing.T) { | |
| 168 | - | tests := []struct { | |
| 169 | - | name string | |
| 170 | - | rawURL string | |
| 171 | - | wantSrc string | |
| 172 | - | wantRef string | |
| 173 | - | wantOrig string | |
| 174 | - | wantOK bool | |
| 175 | - | }{ | |
| 176 | - | { | |
| 177 | - | name: "valid with all params", | |
| 178 | - | rawURL: "https://www.reddit.com/__dumber__/transcode.webm?src=https%3A%2F%2Fv.redd.it%2Fabc%2FHLSPlaylist.m3u8&referer=https%3A%2F%2Fwww.reddit.com%2Fr%2FOpenAI&origin=https%3A%2F%2Fwww.reddit.com", | |
| 179 | - | wantSrc: "https://v.redd.it/abc/HLSPlaylist.m3u8", | |
| 180 | - | wantRef: "https://www.reddit.com/r/OpenAI", | |
| 181 | - | wantOrig: "https://www.reddit.com", | |
| 182 | - | wantOK: true, | |
| 183 | - | }, | |
| 184 | - | { | |
| 185 | - | name: "valid without referer and origin", | |
| 186 | - | rawURL: "https://example.com/__dumber__/transcode.webm?src=https%3A%2F%2Fcdn.example.com%2Fvideo.mp4", | |
| 187 | - | wantSrc: "https://cdn.example.com/video.mp4", | |
| 188 | - | wantRef: "", | |
| 189 | - | wantOrig: "", | |
| 190 | - | wantOK: true, | |
| 191 | - | }, | |
| 192 | - | { | |
| 193 | - | name: "empty URL", | |
| 194 | - | rawURL: "", | |
| 195 | - | wantOK: false, | |
| 196 | - | }, | |
| 197 | - | { | |
| 198 | - | name: "missing src parameter", | |
| 199 | - | rawURL: "https://example.com/__dumber__/transcode.webm?referer=https%3A%2F%2Fexample.com", | |
| 200 | - | wantOK: false, | |
| 201 | - | }, | |
| 202 | - | { | |
| 203 | - | name: "wrong path", | |
| 204 | - | rawURL: "https://example.com/other/path?src=https%3A%2F%2Fcdn.example.com%2Fvideo.mp4", | |
| 205 | - | wantOK: false, | |
| 206 | - | }, | |
| 207 | - | { | |
| 208 | - | name: "src with no host", | |
| 209 | - | rawURL: "https://example.com/__dumber__/transcode.webm?src=not-a-url", | |
| 210 | - | wantOK: false, | |
| 211 | - | }, | |
| 212 | - | { | |
| 213 | - | name: "src with non-http scheme", | |
| 214 | - | rawURL: "https://example.com/__dumber__/transcode.webm?src=ftp%3A%2F%2Fcdn.example.com%2Fvideo.mp4", | |
| 215 | - | wantOK: false, | |
| 216 | - | }, | |
| 217 | - | { | |
| 218 | - | name: "src is empty string", | |
| 219 | - | rawURL: "https://example.com/__dumber__/transcode.webm?src=", | |
| 220 | - | wantOK: false, | |
| 221 | - | }, | |
| 222 | - | { | |
| 223 | - | name: "src is whitespace", | |
| 224 | - | rawURL: "https://example.com/__dumber__/transcode.webm?src=%20%20", | |
| 225 | - | wantOK: false, | |
| 226 | - | }, | |
| 227 | - | { | |
| 228 | - | name: "unparseable URL", | |
| 229 | - | rawURL: "://invalid", | |
| 230 | - | wantOK: false, | |
| 231 | - | }, | |
| 232 | - | } | |
| 233 | - | ||
| 234 | - | for _, tt := range tests { | |
| 235 | - | t.Run(tt.name, func(t *testing.T) { | |
| 236 | - | src, ref, orig, ok := ParseSyntheticTranscodeURL(tt.rawURL) | |
| 237 | - | if ok != tt.wantOK { | |
| 238 | - | t.Fatalf("ParseSyntheticTranscodeURL(%q) ok = %v, want %v", tt.rawURL, ok, tt.wantOK) | |
| 239 | - | } | |
| 240 | - | if !tt.wantOK { | |
| 241 | - | return | |
| 242 | - | } | |
| 243 | - | if src != tt.wantSrc { | |
| 244 | - | t.Errorf("sourceURL = %q, want %q", src, tt.wantSrc) | |
| 245 | - | } | |
| 246 | - | if ref != tt.wantRef { | |
| 247 | - | t.Errorf("referer = %q, want %q", ref, tt.wantRef) | |
| 248 | - | } | |
| 249 | - | if orig != tt.wantOrig { | |
| 250 | - | t.Errorf("origin = %q, want %q", orig, tt.wantOrig) | |
| 251 | - | } | |
| 252 | - | }) | |
| 253 | - | } | |
| 254 | - | } | |
| @@ -1,254 +0,0 @@ | |||||
| 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 | - | } | |||
| 87 | - | ||||
| 88 | - | func TestIsStreamingManifestMIME(t *testing.T) { | |||
| 89 | - | tests := []struct { | |||
| 90 | - | name string | |||
| 91 | - | contentType string | |||
| 92 | - | want bool | |||
| 93 | - | }{ | |||
| 94 | - | {name: "hls apple", contentType: "application/vnd.apple.mpegURL", want: true}, | |||
| 95 | - | {name: "hls x-mpegurl", contentType: "application/x-mpegURL", want: true}, | |||
| 96 | - | {name: "hls audio x-mpegurl", contentType: "audio/x-mpegurl", want: true}, | |||
| 97 | - | {name: "hls audio mpegurl", contentType: "audio/mpegurl", want: true}, | |||
| 98 | - | {name: "dash", contentType: "application/dash+xml", want: true}, | |||
| 99 | - | {name: "dash with charset", contentType: "application/dash+xml; charset=utf-8", want: true}, | |||
| 100 | - | {name: "mp4", contentType: "video/mp4", want: false}, | |||
| 101 | - | {name: "json", contentType: "application/json", want: false}, | |||
| 102 | - | {name: "empty", contentType: "", want: false}, | |||
| 103 | - | } | |||
| 104 | - | ||||
| 105 | - | for _, tt := range tests { | |||
| 106 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 107 | - | got := IsStreamingManifestMIME(tt.contentType) | |||
| 108 | - | if got != tt.want { | |||
| 109 | - | t.Errorf("IsStreamingManifestMIME(%q) = %v, want %v", tt.contentType, got, tt.want) | |||
| 110 | - | } | |||
| 111 | - | }) | |||
| 112 | - | } | |||
| 113 | - | } | |||
| 114 | - | ||||
| 115 | - | func TestIsStreamingManifestURL(t *testing.T) { | |||
| 116 | - | tests := []struct { | |||
| 117 | - | name string | |||
| 118 | - | rawURL string | |||
| 119 | - | want bool | |||
| 120 | - | }{ | |||
| 121 | - | {name: "hls manifest", rawURL: "https://v.redd.it/abc/HLSPlaylist.m3u8?f=sd&v=1", want: true}, | |||
| 122 | - | {name: "dash manifest", rawURL: "https://example.com/video/manifest.mpd", want: true}, | |||
| 123 | - | {name: "uppercase extension", rawURL: "https://example.com/PLAYLIST.M3U8", want: true}, | |||
| 124 | - | {name: "segment ts", rawURL: "https://example.com/chunk.ts", want: false}, | |||
| 125 | - | {name: "plain mp4", rawURL: "https://example.com/video.mp4", want: false}, | |||
| 126 | - | {name: "empty", rawURL: "", want: false}, | |||
| 127 | - | {name: "invalid but obvious", rawURL: "not a url but playlist.m3u8", want: true}, | |||
| 128 | - | } | |||
| 129 | - | ||||
| 130 | - | for _, tt := range tests { | |||
| 131 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 132 | - | got := IsStreamingManifestURL(tt.rawURL) | |||
| 133 | - | if got != tt.want { | |||
| 134 | - | t.Errorf("IsStreamingManifestURL(%q) = %v, want %v", tt.rawURL, got, tt.want) | |||
| 135 | - | } | |||
| 136 | - | }) | |||
| 137 | - | } | |||
| 138 | - | } | |||
| 139 | - | ||||
| 140 | - | func TestIsEagerTranscodeURL(t *testing.T) { | |||
| 141 | - | tests := []struct { | |||
| 142 | - | name string | |||
| 143 | - | rawURL string | |||
| 144 | - | want bool | |||
| 145 | - | }{ | |||
| 146 | - | {name: "hls manifest", rawURL: "https://v.redd.it/abc/HLSPlaylist.m3u8?f=sd&v=1", want: true}, | |||
| 147 | - | {name: "dash manifest", rawURL: "https://example.com/video/manifest.mpd", want: true}, | |||
| 148 | - | {name: "mp4 file", rawURL: "https://example.com/video.mp4", want: true}, | |||
| 149 | - | {name: "mov file", rawURL: "https://example.com/video.mov", want: true}, | |||
| 150 | - | {name: "mkv uppercase", rawURL: "https://example.com/VIDEO.MKV", want: true}, | |||
| 151 | - | {name: "hls segment", rawURL: "https://example.com/chunk.ts", want: false}, | |||
| 152 | - | {name: "webm open codec", rawURL: "https://example.com/video.webm", want: false}, | |||
| 153 | - | {name: "synthetic transcode", rawURL: "https://www.reddit.com/__dumber__/transcode.webm?src=https%3A%2F%2Fv.redd.it%2Fabc%2FHLSPlaylist.m3u8", want: true}, | |||
| 154 | - | {name: "empty", rawURL: "", want: false}, | |||
| 155 | - | } | |||
| 156 | - | ||||
| 157 | - | for _, tt := range tests { | |||
| 158 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 159 | - | got := IsEagerTranscodeURL(tt.rawURL) | |||
| 160 | - | if got != tt.want { | |||
| 161 | - | t.Errorf("IsEagerTranscodeURL(%q) = %v, want %v", tt.rawURL, got, tt.want) | |||
| 162 | - | } | |||
| 163 | - | }) | |||
| 164 | - | } | |||
| 165 | - | } | |||
| 166 | - | ||||
| 167 | - | func TestParseSyntheticTranscodeURL(t *testing.T) { | |||
| 168 | - | tests := []struct { | |||
| 169 | - | name string | |||
| 170 | - | rawURL string | |||
| 171 | - | wantSrc string | |||
| 172 | - | wantRef string | |||
| 173 | - | wantOrig string | |||
| 174 | - | wantOK bool | |||
| 175 | - | }{ | |||
| 176 | - | { | |||
| 177 | - | name: "valid with all params", | |||
| 178 | - | rawURL: "https://www.reddit.com/__dumber__/transcode.webm?src=https%3A%2F%2Fv.redd.it%2Fabc%2FHLSPlaylist.m3u8&referer=https%3A%2F%2Fwww.reddit.com%2Fr%2FOpenAI&origin=https%3A%2F%2Fwww.reddit.com", | |||
| 179 | - | wantSrc: "https://v.redd.it/abc/HLSPlaylist.m3u8", | |||
| 180 | - | wantRef: "https://www.reddit.com/r/OpenAI", | |||
| 181 | - | wantOrig: "https://www.reddit.com", | |||
| 182 | - | wantOK: true, | |||
| 183 | - | }, | |||
| 184 | - | { | |||
| 185 | - | name: "valid without referer and origin", | |||
| 186 | - | rawURL: "https://example.com/__dumber__/transcode.webm?src=https%3A%2F%2Fcdn.example.com%2Fvideo.mp4", | |||
| 187 | - | wantSrc: "https://cdn.example.com/video.mp4", | |||
| 188 | - | wantRef: "", | |||
| 189 | - | wantOrig: "", | |||
| 190 | - | wantOK: true, | |||
| 191 | - | }, | |||
| 192 | - | { | |||
| 193 | - | name: "empty URL", | |||
| 194 | - | rawURL: "", | |||
| 195 | - | wantOK: false, | |||
| 196 | - | }, | |||
| 197 | - | { | |||
| 198 | - | name: "missing src parameter", | |||
| 199 | - | rawURL: "https://example.com/__dumber__/transcode.webm?referer=https%3A%2F%2Fexample.com", | |||
| 200 | - | wantOK: false, | |||
| 201 | - | }, | |||
| 202 | - | { | |||
| 203 | - | name: "wrong path", | |||
| 204 | - | rawURL: "https://example.com/other/path?src=https%3A%2F%2Fcdn.example.com%2Fvideo.mp4", | |||
| 205 | - | wantOK: false, | |||
| 206 | - | }, | |||
| 207 | - | { | |||
| 208 | - | name: "src with no host", | |||
| 209 | - | rawURL: "https://example.com/__dumber__/transcode.webm?src=not-a-url", | |||
| 210 | - | wantOK: false, | |||
| 211 | - | }, | |||
| 212 | - | { | |||
| 213 | - | name: "src with non-http scheme", | |||
| 214 | - | rawURL: "https://example.com/__dumber__/transcode.webm?src=ftp%3A%2F%2Fcdn.example.com%2Fvideo.mp4", | |||
| 215 | - | wantOK: false, | |||
| 216 | - | }, | |||
| 217 | - | { | |||
| 218 | - | name: "src is empty string", | |||
| 219 | - | rawURL: "https://example.com/__dumber__/transcode.webm?src=", | |||
| 220 | - | wantOK: false, | |||
| 221 | - | }, | |||
| 222 | - | { | |||
| 223 | - | name: "src is whitespace", | |||
| 224 | - | rawURL: "https://example.com/__dumber__/transcode.webm?src=%20%20", | |||
| 225 | - | wantOK: false, | |||
| 226 | - | }, | |||
| 227 | - | { | |||
| 228 | - | name: "unparseable URL", | |||
| 229 | - | rawURL: "://invalid", | |||
| 230 | - | wantOK: false, | |||
| 231 | - | }, | |||
| 232 | - | } | |||
| 233 | - | ||||
| 234 | - | for _, tt := range tests { | |||
| 235 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 236 | - | src, ref, orig, ok := ParseSyntheticTranscodeURL(tt.rawURL) | |||
| 237 | - | if ok != tt.wantOK { | |||
| 238 | - | t.Fatalf("ParseSyntheticTranscodeURL(%q) ok = %v, want %v", tt.rawURL, ok, tt.wantOK) | |||
| 239 | - | } | |||
| 240 | - | if !tt.wantOK { | |||
| 241 | - | return | |||
| 242 | - | } | |||
| 243 | - | if src != tt.wantSrc { | |||
| 244 | - | t.Errorf("sourceURL = %q, want %q", src, tt.wantSrc) | |||
| 245 | - | } | |||
| 246 | - | if ref != tt.wantRef { | |||
| 247 | - | t.Errorf("referer = %q, want %q", ref, tt.wantRef) | |||
| 248 | - | } | |||
| 249 | - | if orig != tt.wantOrig { | |||
| 250 | - | t.Errorf("origin = %q, want %q", orig, tt.wantOrig) | |||
| 251 | - | } | |||
| 252 | - | }) | |||
| 253 | - | } | |||
| 254 | - | } | |||
D internal/infrastructure/transcoder/ffmpeg_offsets.go +0 -166
| @@ -1,166 +0,0 @@ | |||
| 1 | - | //go:build !js || !wasm | |
| 2 | - | ||
| 3 | - | package transcoder | |
| 4 | - | ||
| 5 | - | // Offset-based accessors for FFmpeg struct fields not yet exposed | |
| 6 | - | // by purego-ffmpeg. | |
| 7 | - | // | |
| 8 | - | // Validated against: | |
| 9 | - | // FFmpeg 8.1 (n8.1) | |
| 10 | - | // libavcodec 62.28.100 | |
| 11 | - | // libavformat 62.12.100 | |
| 12 | - | // libavutil 60.26.100 | |
| 13 | - | // | |
| 14 | - | // All offsets verified via offsetof() on the installed headers. | |
| 15 | - | ||
| 16 | - | import "unsafe" | |
| 17 | - | ||
| 18 | - | // --------------------------------------------------------------------------- | |
| 19 | - | // AVPacket field offsets (FFmpeg 8.x, libavcodec 62) | |
| 20 | - | // Layout: AVBufferRef* buf (0), int64 pts (8), int64 dts (16), | |
| 21 | - | // uint8* data (24), int size (32), int stream_index (36), int flags (40) | |
| 22 | - | // --------------------------------------------------------------------------- | |
| 23 | - | ||
| 24 | - | const ( | |
| 25 | - | offsetPktStreamIndex = 36 | |
| 26 | - | ) | |
| 27 | - | ||
| 28 | - | func pktStreamIndex(pkt unsafe.Pointer) int32 { | |
| 29 | - | return *(*int32)(unsafe.Add(pkt, offsetPktStreamIndex)) | |
| 30 | - | } | |
| 31 | - | ||
| 32 | - | func pktSetStreamIndex(pkt unsafe.Pointer, idx int32) { | |
| 33 | - | *(*int32)(unsafe.Add(pkt, offsetPktStreamIndex)) = idx | |
| 34 | - | } | |
| 35 | - | ||
| 36 | - | // --------------------------------------------------------------------------- | |
| 37 | - | // AVFrame field offsets (FFmpeg 8.x, libavutil 60) | |
| 38 | - | // Layout (relevant fields): | |
| 39 | - | // data[8] at offset 0 (8 pointers = 64 bytes) | |
| 40 | - | // linesize[8] at offset 64 (8 x int32 = 32 bytes) | |
| 41 | - | // ... | |
| 42 | - | // width at offset 104 | |
| 43 | - | // height at offset 108 | |
| 44 | - | // nb_samples at offset 112 | |
| 45 | - | // format at offset 116 | |
| 46 | - | // pts at offset 136 | |
| 47 | - | // sample_rate at offset 180 | |
| 48 | - | // hw_frames_ctx at offset 328 (AVBufferRef*) | |
| 49 | - | // ch_layout at offset 384 (AVChannelLayout, 24 bytes) | |
| 50 | - | // --------------------------------------------------------------------------- | |
| 51 | - | ||
| 52 | - | const ( | |
| 53 | - | offsetFrameWidth = 104 | |
| 54 | - | offsetFrameHeight = 108 | |
| 55 | - | offsetFrameNbSamples = 112 | |
| 56 | - | offsetFrameFormat = 116 | |
| 57 | - | offsetFrameHWFramesCtx = 328 | |
| 58 | - | offsetFramePts = 136 | |
| 59 | - | offsetFrameSampleRate = 180 | |
| 60 | - | ) | |
| 61 | - | ||
| 62 | - | func frameWidth(f unsafe.Pointer) int32 { | |
| 63 | - | return *(*int32)(unsafe.Add(f, offsetFrameWidth)) | |
| 64 | - | } | |
| 65 | - | ||
| 66 | - | func frameSetWidth(f unsafe.Pointer, v int32) { | |
| 67 | - | *(*int32)(unsafe.Add(f, offsetFrameWidth)) = v | |
| 68 | - | } | |
| 69 | - | ||
| 70 | - | func frameHeight(f unsafe.Pointer) int32 { | |
| 71 | - | return *(*int32)(unsafe.Add(f, offsetFrameHeight)) | |
| 72 | - | } | |
| 73 | - | ||
| 74 | - | func frameSetHeight(f unsafe.Pointer, v int32) { | |
| 75 | - | *(*int32)(unsafe.Add(f, offsetFrameHeight)) = v | |
| 76 | - | } | |
| 77 | - | ||
| 78 | - | func frameNbSamples(f unsafe.Pointer) int32 { | |
| 79 | - | return *(*int32)(unsafe.Add(f, offsetFrameNbSamples)) | |
| 80 | - | } | |
| 81 | - | ||
| 82 | - | func frameSetNbSamples(f unsafe.Pointer, v int32) { | |
| 83 | - | *(*int32)(unsafe.Add(f, offsetFrameNbSamples)) = v | |
| 84 | - | } | |
| 85 | - | ||
| 86 | - | func frameFormat(f unsafe.Pointer) int32 { | |
| 87 | - | return *(*int32)(unsafe.Add(f, offsetFrameFormat)) | |
| 88 | - | } | |
| 89 | - | ||
| 90 | - | func frameSetFormat(f unsafe.Pointer, v int32) { | |
| 91 | - | *(*int32)(unsafe.Add(f, offsetFrameFormat)) = v | |
| 92 | - | } | |
| 93 | - | ||
| 94 | - | func frameSetHWFramesCtx(f, v unsafe.Pointer) { | |
| 95 | - | *(*unsafe.Pointer)(unsafe.Add(f, offsetFrameHWFramesCtx)) = v | |
| 96 | - | } | |
| 97 | - | ||
| 98 | - | func framePts(f unsafe.Pointer) int64 { | |
| 99 | - | return *(*int64)(unsafe.Add(f, offsetFramePts)) | |
| 100 | - | } | |
| 101 | - | ||
| 102 | - | func frameSetPts(f unsafe.Pointer, v int64) { | |
| 103 | - | *(*int64)(unsafe.Add(f, offsetFramePts)) = v | |
| 104 | - | } | |
| 105 | - | ||
| 106 | - | func frameSetSampleRate(f unsafe.Pointer, v int32) { | |
| 107 | - | *(*int32)(unsafe.Add(f, offsetFrameSampleRate)) = v | |
| 108 | - | } | |
| 109 | - | ||
| 110 | - | // --------------------------------------------------------------------------- | |
| 111 | - | // AVCodecContext additional offsets (FFmpeg 8.x, libavcodec 62) | |
| 112 | - | // --------------------------------------------------------------------------- | |
| 113 | - | ||
| 114 | - | const ( | |
| 115 | - | offsetCodecCtxGopSize = 332 | |
| 116 | - | offsetCodecCtxBitRate = 56 | |
| 117 | - | offsetCodecCtxChLayout = 352 // AVChannelLayout struct (24 bytes) | |
| 118 | - | ) | |
| 119 | - | ||
| 120 | - | func codecCtxSetGopSize(ctx unsafe.Pointer, v int32) { | |
| 121 | - | *(*int32)(unsafe.Add(ctx, offsetCodecCtxGopSize)) = v | |
| 122 | - | } | |
| 123 | - | ||
| 124 | - | func codecCtxSetBitRate(ctx unsafe.Pointer, v int64) { | |
| 125 | - | *(*int64)(unsafe.Add(ctx, offsetCodecCtxBitRate)) = v | |
| 126 | - | } | |
| 127 | - | ||
| 128 | - | // chLayoutCopy copies the 24-byte AVChannelLayout from src to dst. | |
| 129 | - | // Both must point to the ch_layout field within their respective AVCodecContext. | |
| 130 | - | // | |
| 131 | - | //nolint:gosec // These offsets target fixed-size FFmpeg structs validated against headers. | |
| 132 | - | func codecCtxCopyChLayout(dst, src unsafe.Pointer) { | |
| 133 | - | dstLayout := unsafe.Add(dst, offsetCodecCtxChLayout) | |
| 134 | - | srcLayout := unsafe.Add(src, offsetCodecCtxChLayout) | |
| 135 | - | // AVChannelLayout is 24 bytes in FFmpeg 8.x. | |
| 136 | - | copy( | |
| 137 | - | unsafe.Slice((*byte)(dstLayout), channelLayoutSize), | |
| 138 | - | unsafe.Slice((*byte)(srcLayout), channelLayoutSize), | |
| 139 | - | ) | |
| 140 | - | } | |
| 141 | - | ||
| 142 | - | // --------------------------------------------------------------------------- | |
| 143 | - | // SwrContext channel layout offsets (FFmpeg 8.x, libswresample) | |
| 144 | - | // SwrContext is opaque; offsets verified by writing known AVChannelLayout | |
| 145 | - | // values via av_opt_set_chlayout and scanning the resulting memory. | |
| 146 | - | // --------------------------------------------------------------------------- | |
| 147 | - | ||
| 148 | - | const ( | |
| 149 | - | channelLayoutSize = 24 | |
| 150 | - | ||
| 151 | - | offsetSwrInChLayout = 192 // AVChannelLayout (24 bytes) | |
| 152 | - | offsetSwrOutChLayout = 216 // AVChannelLayout (24 bytes) | |
| 153 | - | ) | |
| 154 | - | ||
| 155 | - | // swrCopyChLayoutFromCodecCtx copies an AVChannelLayout (24 bytes) from | |
| 156 | - | // an AVCodecContext's ch_layout field into the SwrContext's in/out ch_layout. | |
| 157 | - | // | |
| 158 | - | //nolint:gosec // These offsets target fixed-size FFmpeg structs validated against headers. | |
| 159 | - | func swrSetChLayoutFromCodecCtx(swr, codecCtx unsafe.Pointer, swrOffset int) { | |
| 160 | - | dst := unsafe.Add(swr, swrOffset) | |
| 161 | - | src := unsafe.Add(codecCtx, offsetCodecCtxChLayout) | |
| 162 | - | copy( | |
| 163 | - | unsafe.Slice((*byte)(dst), channelLayoutSize), | |
| 164 | - | unsafe.Slice((*byte)(src), channelLayoutSize), | |
| 165 | - | ) | |
| 166 | - | } | |
| @@ -1,166 +0,0 @@ | |||||
| 1 | - | //go:build !js || !wasm | |||
| 2 | - | ||||
| 3 | - | package transcoder | |||
| 4 | - | ||||
| 5 | - | // Offset-based accessors for FFmpeg struct fields not yet exposed | |||
| 6 | - | // by purego-ffmpeg. | |||
| 7 | - | // | |||
| 8 | - | // Validated against: | |||
| 9 | - | // FFmpeg 8.1 (n8.1) | |||
| 10 | - | // libavcodec 62.28.100 | |||
| 11 | - | // libavformat 62.12.100 | |||
| 12 | - | // libavutil 60.26.100 | |||
| 13 | - | // | |||
| 14 | - | // All offsets verified via offsetof() on the installed headers. | |||
| 15 | - | ||||
| 16 | - | import "unsafe" | |||
| 17 | - | ||||
| 18 | - | // --------------------------------------------------------------------------- | |||
| 19 | - | // AVPacket field offsets (FFmpeg 8.x, libavcodec 62) | |||
| 20 | - | // Layout: AVBufferRef* buf (0), int64 pts (8), int64 dts (16), | |||
| 21 | - | // uint8* data (24), int size (32), int stream_index (36), int flags (40) | |||
| 22 | - | // --------------------------------------------------------------------------- | |||
| 23 | - | ||||
| 24 | - | const ( | |||
| 25 | - | offsetPktStreamIndex = 36 | |||
| 26 | - | ) | |||
| 27 | - | ||||
| 28 | - | func pktStreamIndex(pkt unsafe.Pointer) int32 { | |||
| 29 | - | return *(*int32)(unsafe.Add(pkt, offsetPktStreamIndex)) | |||
| 30 | - | } | |||
| 31 | - | ||||
| 32 | - | func pktSetStreamIndex(pkt unsafe.Pointer, idx int32) { | |||
| 33 | - | *(*int32)(unsafe.Add(pkt, offsetPktStreamIndex)) = idx | |||
| 34 | - | } | |||
| 35 | - | ||||
| 36 | - | // --------------------------------------------------------------------------- | |||
| 37 | - | // AVFrame field offsets (FFmpeg 8.x, libavutil 60) | |||
| 38 | - | // Layout (relevant fields): | |||
| 39 | - | // data[8] at offset 0 (8 pointers = 64 bytes) | |||
| 40 | - | // linesize[8] at offset 64 (8 x int32 = 32 bytes) | |||
| 41 | - | // ... | |||
| 42 | - | // width at offset 104 | |||
| 43 | - | // height at offset 108 | |||
| 44 | - | // nb_samples at offset 112 | |||
| 45 | - | // format at offset 116 | |||
| 46 | - | // pts at offset 136 | |||
| 47 | - | // sample_rate at offset 180 | |||
| 48 | - | // hw_frames_ctx at offset 328 (AVBufferRef*) | |||
| 49 | - | // ch_layout at offset 384 (AVChannelLayout, 24 bytes) | |||
| 50 | - | // --------------------------------------------------------------------------- | |||
| 51 | - | ||||
| 52 | - | const ( | |||
| 53 | - | offsetFrameWidth = 104 | |||
| 54 | - | offsetFrameHeight = 108 | |||
| 55 | - | offsetFrameNbSamples = 112 | |||
| 56 | - | offsetFrameFormat = 116 | |||
| 57 | - | offsetFrameHWFramesCtx = 328 | |||
| 58 | - | offsetFramePts = 136 | |||
| 59 | - | offsetFrameSampleRate = 180 | |||
| 60 | - | ) | |||
| 61 | - | ||||
| 62 | - | func frameWidth(f unsafe.Pointer) int32 { | |||
| 63 | - | return *(*int32)(unsafe.Add(f, offsetFrameWidth)) | |||
| 64 | - | } | |||
| 65 | - | ||||
| 66 | - | func frameSetWidth(f unsafe.Pointer, v int32) { | |||
| 67 | - | *(*int32)(unsafe.Add(f, offsetFrameWidth)) = v | |||
| 68 | - | } | |||
| 69 | - | ||||
| 70 | - | func frameHeight(f unsafe.Pointer) int32 { | |||
| 71 | - | return *(*int32)(unsafe.Add(f, offsetFrameHeight)) | |||
| 72 | - | } | |||
| 73 | - | ||||
| 74 | - | func frameSetHeight(f unsafe.Pointer, v int32) { | |||
| 75 | - | *(*int32)(unsafe.Add(f, offsetFrameHeight)) = v | |||
| 76 | - | } | |||
| 77 | - | ||||
| 78 | - | func frameNbSamples(f unsafe.Pointer) int32 { | |||
| 79 | - | return *(*int32)(unsafe.Add(f, offsetFrameNbSamples)) | |||
| 80 | - | } | |||
| 81 | - | ||||
| 82 | - | func frameSetNbSamples(f unsafe.Pointer, v int32) { | |||
| 83 | - | *(*int32)(unsafe.Add(f, offsetFrameNbSamples)) = v | |||
| 84 | - | } | |||
| 85 | - | ||||
| 86 | - | func frameFormat(f unsafe.Pointer) int32 { | |||
| 87 | - | return *(*int32)(unsafe.Add(f, offsetFrameFormat)) | |||
| 88 | - | } | |||
| 89 | - | ||||
| 90 | - | func frameSetFormat(f unsafe.Pointer, v int32) { | |||
| 91 | - | *(*int32)(unsafe.Add(f, offsetFrameFormat)) = v | |||
| 92 | - | } | |||
| 93 | - | ||||
| 94 | - | func frameSetHWFramesCtx(f, v unsafe.Pointer) { | |||
| 95 | - | *(*unsafe.Pointer)(unsafe.Add(f, offsetFrameHWFramesCtx)) = v | |||
| 96 | - | } | |||
| 97 | - | ||||
| 98 | - | func framePts(f unsafe.Pointer) int64 { | |||
| 99 | - | return *(*int64)(unsafe.Add(f, offsetFramePts)) | |||
| 100 | - | } | |||
| 101 | - | ||||
| 102 | - | func frameSetPts(f unsafe.Pointer, v int64) { | |||
| 103 | - | *(*int64)(unsafe.Add(f, offsetFramePts)) = v | |||
| 104 | - | } | |||
| 105 | - | ||||
| 106 | - | func frameSetSampleRate(f unsafe.Pointer, v int32) { | |||
| 107 | - | *(*int32)(unsafe.Add(f, offsetFrameSampleRate)) = v | |||
| 108 | - | } | |||
| 109 | - | ||||
| 110 | - | // --------------------------------------------------------------------------- | |||
| 111 | - | // AVCodecContext additional offsets (FFmpeg 8.x, libavcodec 62) | |||
| 112 | - | // --------------------------------------------------------------------------- | |||
| 113 | - | ||||
| 114 | - | const ( | |||
| 115 | - | offsetCodecCtxGopSize = 332 | |||
| 116 | - | offsetCodecCtxBitRate = 56 | |||
| 117 | - | offsetCodecCtxChLayout = 352 // AVChannelLayout struct (24 bytes) | |||
| 118 | - | ) | |||
| 119 | - | ||||
| 120 | - | func codecCtxSetGopSize(ctx unsafe.Pointer, v int32) { | |||
| 121 | - | *(*int32)(unsafe.Add(ctx, offsetCodecCtxGopSize)) = v | |||
| 122 | - | } | |||
| 123 | - | ||||
| 124 | - | func codecCtxSetBitRate(ctx unsafe.Pointer, v int64) { | |||
| 125 | - | *(*int64)(unsafe.Add(ctx, offsetCodecCtxBitRate)) = v | |||
| 126 | - | } | |||
| 127 | - | ||||
| 128 | - | // chLayoutCopy copies the 24-byte AVChannelLayout from src to dst. | |||
| 129 | - | // Both must point to the ch_layout field within their respective AVCodecContext. | |||
| 130 | - | // | |||
| 131 | - | //nolint:gosec // These offsets target fixed-size FFmpeg structs validated against headers. | |||
| 132 | - | func codecCtxCopyChLayout(dst, src unsafe.Pointer) { | |||
| 133 | - | dstLayout := unsafe.Add(dst, offsetCodecCtxChLayout) | |||
| 134 | - | srcLayout := unsafe.Add(src, offsetCodecCtxChLayout) | |||
| 135 | - | // AVChannelLayout is 24 bytes in FFmpeg 8.x. | |||
| 136 | - | copy( | |||
| 137 | - | unsafe.Slice((*byte)(dstLayout), channelLayoutSize), | |||
| 138 | - | unsafe.Slice((*byte)(srcLayout), channelLayoutSize), | |||
| 139 | - | ) | |||
| 140 | - | } | |||
| 141 | - | ||||
| 142 | - | // --------------------------------------------------------------------------- | |||
| 143 | - | // SwrContext channel layout offsets (FFmpeg 8.x, libswresample) | |||
| 144 | - | // SwrContext is opaque; offsets verified by writing known AVChannelLayout | |||
| 145 | - | // values via av_opt_set_chlayout and scanning the resulting memory. | |||
| 146 | - | // --------------------------------------------------------------------------- | |||
| 147 | - | ||||
| 148 | - | const ( | |||
| 149 | - | channelLayoutSize = 24 | |||
| 150 | - | ||||
| 151 | - | offsetSwrInChLayout = 192 // AVChannelLayout (24 bytes) | |||
| 152 | - | offsetSwrOutChLayout = 216 // AVChannelLayout (24 bytes) | |||
| 153 | - | ) | |||
| 154 | - | ||||
| 155 | - | // swrCopyChLayoutFromCodecCtx copies an AVChannelLayout (24 bytes) from | |||
| 156 | - | // an AVCodecContext's ch_layout field into the SwrContext's in/out ch_layout. | |||
| 157 | - | // | |||
| 158 | - | //nolint:gosec // These offsets target fixed-size FFmpeg structs validated against headers. | |||
| 159 | - | func swrSetChLayoutFromCodecCtx(swr, codecCtx unsafe.Pointer, swrOffset int) { | |||
| 160 | - | dst := unsafe.Add(swr, swrOffset) | |||
| 161 | - | src := unsafe.Add(codecCtx, offsetCodecCtxChLayout) | |||
| 162 | - | copy( | |||
| 163 | - | unsafe.Slice((*byte)(dst), channelLayoutSize), | |||
| 164 | - | unsafe.Slice((*byte)(src), channelLayoutSize), | |||
| 165 | - | ) | |||
| 166 | - | } | |||
D internal/infrastructure/transcoder/http_client_test.go +0 -138
| @@ -1,138 +0,0 @@ | |||
| 1 | - | //go:build !js || !wasm | |
| 2 | - | ||
| 3 | - | package transcoder | |
| 4 | - | ||
| 5 | - | import ( | |
| 6 | - | "context" | |
| 7 | - | "net" | |
| 8 | - | "net/http" | |
| 9 | - | "net/url" | |
| 10 | - | "testing" | |
| 11 | - | ||
| 12 | - | "github.com/rs/zerolog" | |
| 13 | - | ||
| 14 | - | "github.com/bnema/dumber/internal/application/port" | |
| 15 | - | ) | |
| 16 | - | ||
| 17 | - | func TestValidateHTTPTranscodeURLRejectsPrivateTargets(t *testing.T) { | |
| 18 | - | source, err := url.Parse("http://127.0.0.1/video.mp4") | |
| 19 | - | if err != nil { | |
| 20 | - | t.Fatal(err) | |
| 21 | - | } | |
| 22 | - | ||
| 23 | - | err = validateHTTPTranscodeURL(context.Background(), source) | |
| 24 | - | if err == nil { | |
| 25 | - | t.Fatal("validateHTTPTranscodeURL() expected error for loopback target") | |
| 26 | - | } | |
| 27 | - | } | |
| 28 | - | ||
| 29 | - | func TestIsBlockedTranscodeIPRejectsReservedInternalRanges(t *testing.T) { | |
| 30 | - | tests := []string{ | |
| 31 | - | "100.64.0.1", | |
| 32 | - | "198.18.0.1", | |
| 33 | - | "192.0.2.1", | |
| 34 | - | "64:ff9b::a00:1", | |
| 35 | - | "2001::1", | |
| 36 | - | "2001:db8::1", | |
| 37 | - | "2002:0a00:0001::1", | |
| 38 | - | } | |
| 39 | - | for _, rawIP := range tests { | |
| 40 | - | t.Run(rawIP, func(t *testing.T) { | |
| 41 | - | if !isBlockedTranscodeIP(net.ParseIP(rawIP)) { | |
| 42 | - | t.Fatalf("isBlockedTranscodeIP(%s) = false, want true", rawIP) | |
| 43 | - | } | |
| 44 | - | }) | |
| 45 | - | } | |
| 46 | - | } | |
| 47 | - | ||
| 48 | - | func TestValidateHTTPTranscodeURLRejectsUnsupportedSchemes(t *testing.T) { | |
| 49 | - | source, err := url.Parse("file:///etc/passwd") | |
| 50 | - | if err != nil { | |
| 51 | - | t.Fatal(err) | |
| 52 | - | } | |
| 53 | - | ||
| 54 | - | err = validateHTTPTranscodeURL(context.Background(), source) | |
| 55 | - | if err == nil { | |
| 56 | - | t.Fatal("validateHTTPTranscodeURL() expected error for unsupported scheme") | |
| 57 | - | } | |
| 58 | - | } | |
| 59 | - | ||
| 60 | - | func TestValidateHTTPTranscodeURLRejectsStreamingManifestURL(t *testing.T) { | |
| 61 | - | source, err := url.Parse("https://93.184.216.34/playlist.m3u8") | |
| 62 | - | if err != nil { | |
| 63 | - | t.Fatal(err) | |
| 64 | - | } | |
| 65 | - | ||
| 66 | - | err = validateHTTPTranscodeURL(context.Background(), source) | |
| 67 | - | if err == nil { | |
| 68 | - | t.Fatal("validateHTTPTranscodeURL() expected error for streaming manifest") | |
| 69 | - | } | |
| 70 | - | } | |
| 71 | - | ||
| 72 | - | func TestValidateHTTPTranscodeURLAllowsPublicIPAddress(t *testing.T) { | |
| 73 | - | source, err := url.Parse("https://93.184.216.34/video.mp4") | |
| 74 | - | if err != nil { | |
| 75 | - | t.Fatal(err) | |
| 76 | - | } | |
| 77 | - | ||
| 78 | - | if err := validateHTTPTranscodeURL(context.Background(), source); err != nil { | |
| 79 | - | t.Fatalf("validateHTTPTranscodeURL() unexpected error: %v", err) | |
| 80 | - | } | |
| 81 | - | } | |
| 82 | - | ||
| 83 | - | func TestOpenInputFormatContextRejectsStreamingManifests(t *testing.T) { | |
| 84 | - | p := newPipeline("test", port.HWCapabilities{}, "https://example.com/playlist.m3u8", nil, "", nil, zerolog.Nop()) | |
| 85 | - | ||
| 86 | - | _, _, err := p.openInputFormatContext(context.Background()) | |
| 87 | - | if err == nil { | |
| 88 | - | t.Fatal("openInputFormatContext() expected error for streaming manifest") | |
| 89 | - | } | |
| 90 | - | } | |
| 91 | - | ||
| 92 | - | func TestIsStreamingManifestResponseDetectsContentType(t *testing.T) { | |
| 93 | - | resp := &http.Response{Header: http.Header{"Content-Type": []string{"application/vnd.apple.mpegurl"}}} | |
| 94 | - | if !isStreamingManifestResponse(resp) { | |
| 95 | - | t.Fatal("isStreamingManifestResponse() = false, want true") | |
| 96 | - | } | |
| 97 | - | } | |
| 98 | - | ||
| 99 | - | func TestValidateTranscodeRedirectRejectsPrivateTarget(t *testing.T) { | |
| 100 | - | requestURL, err := url.Parse("http://10.0.0.1/private.mp4") | |
| 101 | - | if err != nil { | |
| 102 | - | t.Fatal(err) | |
| 103 | - | } | |
| 104 | - | ||
| 105 | - | err = validateTranscodeRedirect(&http.Request{URL: requestURL}, nil) | |
| 106 | - | if err == nil { | |
| 107 | - | t.Fatal("validateTranscodeRedirect() expected error for private redirect target") | |
| 108 | - | } | |
| 109 | - | } | |
| 110 | - | ||
| 111 | - | func TestValidateTranscodeRedirectRejectsTenHopRedirect(t *testing.T) { | |
| 112 | - | requestURL, err := url.Parse("https://93.184.216.34/video.mp4") | |
| 113 | - | if err != nil { | |
| 114 | - | t.Fatal(err) | |
| 115 | - | } | |
| 116 | - | ||
| 117 | - | via := make([]*http.Request, 10) | |
| 118 | - | for i := range via { | |
| 119 | - | via[i] = &http.Request{URL: requestURL} | |
| 120 | - | } | |
| 121 | - | ||
| 122 | - | err = validateTranscodeRedirect(&http.Request{URL: requestURL}, via) | |
| 123 | - | if err == nil { | |
| 124 | - | t.Fatal("validateTranscodeRedirect() expected error for ten-hop redirect") | |
| 125 | - | } | |
| 126 | - | } | |
| 127 | - | ||
| 128 | - | func TestValidateTranscodeRedirectRejectsStreamingManifestTarget(t *testing.T) { | |
| 129 | - | requestURL, err := url.Parse("https://93.184.216.34/playlist.m3u8") | |
| 130 | - | if err != nil { | |
| 131 | - | t.Fatal(err) | |
| 132 | - | } | |
| 133 | - | ||
| 134 | - | err = validateTranscodeRedirect(&http.Request{URL: requestURL}, nil) | |
| 135 | - | if err == nil { | |
| 136 | - | t.Fatal("validateTranscodeRedirect() expected error for manifest redirect target") | |
| 137 | - | } | |
| 138 | - | } | |
| @@ -1,138 +0,0 @@ | |||||
| 1 | - | //go:build !js || !wasm | |||
| 2 | - | ||||
| 3 | - | package transcoder | |||
| 4 | - | ||||
| 5 | - | import ( | |||
| 6 | - | "context" | |||
| 7 | - | "net" | |||
| 8 | - | "net/http" | |||
| 9 | - | "net/url" | |||
| 10 | - | "testing" | |||
| 11 | - | ||||
| 12 | - | "github.com/rs/zerolog" | |||
| 13 | - | ||||
| 14 | - | "github.com/bnema/dumber/internal/application/port" | |||
| 15 | - | ) | |||
| 16 | - | ||||
| 17 | - | func TestValidateHTTPTranscodeURLRejectsPrivateTargets(t *testing.T) { | |||
| 18 | - | source, err := url.Parse("http://127.0.0.1/video.mp4") | |||
| 19 | - | if err != nil { | |||
| 20 | - | t.Fatal(err) | |||
| 21 | - | } | |||
| 22 | - | ||||
| 23 | - | err = validateHTTPTranscodeURL(context.Background(), source) | |||
| 24 | - | if err == nil { | |||
| 25 | - | t.Fatal("validateHTTPTranscodeURL() expected error for loopback target") | |||
| 26 | - | } | |||
| 27 | - | } | |||
| 28 | - | ||||
| 29 | - | func TestIsBlockedTranscodeIPRejectsReservedInternalRanges(t *testing.T) { | |||
| 30 | - | tests := []string{ | |||
| 31 | - | "100.64.0.1", | |||
| 32 | - | "198.18.0.1", | |||
| 33 | - | "192.0.2.1", | |||
| 34 | - | "64:ff9b::a00:1", | |||
| 35 | - | "2001::1", | |||
| 36 | - | "2001:db8::1", | |||
| 37 | - | "2002:0a00:0001::1", | |||
| 38 | - | } | |||
| 39 | - | for _, rawIP := range tests { | |||
| 40 | - | t.Run(rawIP, func(t *testing.T) { | |||
| 41 | - | if !isBlockedTranscodeIP(net.ParseIP(rawIP)) { | |||
| 42 | - | t.Fatalf("isBlockedTranscodeIP(%s) = false, want true", rawIP) | |||
| 43 | - | } | |||
| 44 | - | }) | |||
| 45 | - | } | |||
| 46 | - | } | |||
| 47 | - | ||||
| 48 | - | func TestValidateHTTPTranscodeURLRejectsUnsupportedSchemes(t *testing.T) { | |||
| 49 | - | source, err := url.Parse("file:///etc/passwd") | |||
| 50 | - | if err != nil { | |||
| 51 | - | t.Fatal(err) | |||
| 52 | - | } | |||
| 53 | - | ||||
| 54 | - | err = validateHTTPTranscodeURL(context.Background(), source) | |||
| 55 | - | if err == nil { | |||
| 56 | - | t.Fatal("validateHTTPTranscodeURL() expected error for unsupported scheme") | |||
| 57 | - | } | |||
| 58 | - | } | |||
| 59 | - | ||||
| 60 | - | func TestValidateHTTPTranscodeURLRejectsStreamingManifestURL(t *testing.T) { | |||
| 61 | - | source, err := url.Parse("https://93.184.216.34/playlist.m3u8") | |||
| 62 | - | if err != nil { | |||
| 63 | - | t.Fatal(err) | |||
| 64 | - | } | |||
| 65 | - | ||||
| 66 | - | err = validateHTTPTranscodeURL(context.Background(), source) | |||
| 67 | - | if err == nil { | |||
| 68 | - | t.Fatal("validateHTTPTranscodeURL() expected error for streaming manifest") | |||
| 69 | - | } | |||
| 70 | - | } | |||
| 71 | - | ||||
| 72 | - | func TestValidateHTTPTranscodeURLAllowsPublicIPAddress(t *testing.T) { | |||
| 73 | - | source, err := url.Parse("https://93.184.216.34/video.mp4") | |||
| 74 | - | if err != nil { | |||
| 75 | - | t.Fatal(err) | |||
| 76 | - | } | |||
| 77 | - | ||||
| 78 | - | if err := validateHTTPTranscodeURL(context.Background(), source); err != nil { | |||
| 79 | - | t.Fatalf("validateHTTPTranscodeURL() unexpected error: %v", err) | |||
| 80 | - | } | |||
| 81 | - | } | |||
| 82 | - | ||||
| 83 | - | func TestOpenInputFormatContextRejectsStreamingManifests(t *testing.T) { | |||
| 84 | - | p := newPipeline("test", port.HWCapabilities{}, "https://example.com/playlist.m3u8", nil, "", nil, zerolog.Nop()) | |||
| 85 | - | ||||
| 86 | - | _, _, err := p.openInputFormatContext(context.Background()) | |||
| 87 | - | if err == nil { | |||
| 88 | - | t.Fatal("openInputFormatContext() expected error for streaming manifest") | |||
| 89 | - | } | |||
| 90 | - | } | |||
| 91 | - | ||||
| 92 | - | func TestIsStreamingManifestResponseDetectsContentType(t *testing.T) { | |||
| 93 | - | resp := &http.Response{Header: http.Header{"Content-Type": []string{"application/vnd.apple.mpegurl"}}} | |||
| 94 | - | if !isStreamingManifestResponse(resp) { | |||
| 95 | - | t.Fatal("isStreamingManifestResponse() = false, want true") | |||
| 96 | - | } | |||
| 97 | - | } | |||
| 98 | - | ||||
| 99 | - | func TestValidateTranscodeRedirectRejectsPrivateTarget(t *testing.T) { | |||
| 100 | - | requestURL, err := url.Parse("http://10.0.0.1/private.mp4") | |||
| 101 | - | if err != nil { | |||
| 102 | - | t.Fatal(err) | |||
| 103 | - | } | |||
| 104 | - | ||||
| 105 | - | err = validateTranscodeRedirect(&http.Request{URL: requestURL}, nil) | |||
| 106 | - | if err == nil { | |||
| 107 | - | t.Fatal("validateTranscodeRedirect() expected error for private redirect target") | |||
| 108 | - | } | |||
| 109 | - | } | |||
| 110 | - | ||||
| 111 | - | func TestValidateTranscodeRedirectRejectsTenHopRedirect(t *testing.T) { | |||
| 112 | - | requestURL, err := url.Parse("https://93.184.216.34/video.mp4") | |||
| 113 | - | if err != nil { | |||
| 114 | - | t.Fatal(err) | |||
| 115 | - | } | |||
| 116 | - | ||||
| 117 | - | via := make([]*http.Request, 10) | |||
| 118 | - | for i := range via { | |||
| 119 | - | via[i] = &http.Request{URL: requestURL} | |||
| 120 | - | } | |||
| 121 | - | ||||
| 122 | - | err = validateTranscodeRedirect(&http.Request{URL: requestURL}, via) | |||
| 123 | - | if err == nil { | |||
| 124 | - | t.Fatal("validateTranscodeRedirect() expected error for ten-hop redirect") | |||
| 125 | - | } | |||
| 126 | - | } | |||
| 127 | - | ||||
| 128 | - | func TestValidateTranscodeRedirectRejectsStreamingManifestTarget(t *testing.T) { | |||
| 129 | - | requestURL, err := url.Parse("https://93.184.216.34/playlist.m3u8") | |||
| 130 | - | if err != nil { | |||
| 131 | - | t.Fatal(err) | |||
| 132 | - | } | |||
| 133 | - | ||||
| 134 | - | err = validateTranscodeRedirect(&http.Request{URL: requestURL}, nil) | |||
| 135 | - | if err == nil { | |||
| 136 | - | t.Fatal("validateTranscodeRedirect() expected error for manifest redirect target") | |||
| 137 | - | } | |||
| 138 | - | } | |||
D internal/infrastructure/transcoder/hwprobe.go +0 -130
| @@ -1,130 +0,0 @@ | |||
| 1 | - | //go:build !js || !wasm | |
| 2 | - | ||
| 3 | - | package transcoder | |
| 4 | - | ||
| 5 | - | import ( | |
| 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 | - | const hwAPINameVAAPI = "vaapi" | |
| 22 | - | ||
| 23 | - | // isUsableEncoder returns true only for hardware encoders whose output | |
| 24 | - | // codec is open and decodable by CEF (AV1, VP9). | |
| 25 | - | func isUsableEncoder(name string) bool { | |
| 26 | - | return usableEncoders[name] | |
| 27 | - | } | |
| 28 | - | ||
| 29 | - | // hwProfile groups the encoder/decoder names to probe for a given API. | |
| 30 | - | type hwProfile struct { | |
| 31 | - | api string // "vaapi" or "nvenc" | |
| 32 | - | deviceType int32 | |
| 33 | - | encoders []string | |
| 34 | - | decoders []string | |
| 35 | - | } | |
| 36 | - | ||
| 37 | - | var vaAPIProfile = hwProfile{ | |
| 38 | - | api: hwAPINameVAAPI, | |
| 39 | - | deviceType: int32(ffmpeg.HwdeviceTypeVaapi), | |
| 40 | - | encoders: []string{"av1_vaapi", "vp9_vaapi", "h264_vaapi"}, | |
| 41 | - | decoders: []string{"h264_vaapi", "hevc_vaapi"}, | |
| 42 | - | } | |
| 43 | - | ||
| 44 | - | var nvencProfile = hwProfile{ | |
| 45 | - | api: "nvenc", | |
| 46 | - | deviceType: int32(ffmpeg.HwdeviceTypeCuda), | |
| 47 | - | // hevc_nvenc and h264_nvenc are probed for logging/diagnostic purposes | |
| 48 | - | // but filtered as non-usable by isUsableEncoder (only open codecs like | |
| 49 | - | // AV1 and VP9 are usable in CEF's minimal build). | |
| 50 | - | encoders: []string{"av1_nvenc", "hevc_nvenc", "h264_nvenc"}, | |
| 51 | - | decoders: []string{"h264_cuvid", "hevc_cuvid"}, | |
| 52 | - | } | |
| 53 | - | ||
| 54 | - | // ProbeGPU detects available GPU hardware encoding capabilities. | |
| 55 | - | // hwaccelPref selects which APIs to try: "auto" (VAAPI then CUDA), | |
| 56 | - | // "vaapi" (VAAPI only), or "nvenc" (CUDA only). | |
| 57 | - | // Returns empty HWCapabilities if no compatible GPU encoder is found. | |
| 58 | - | func ProbeGPU(hwaccelPref string, logger *zerolog.Logger) port.HWCapabilities { | |
| 59 | - | if err := ffmpeg.Init(); err != nil { | |
| 60 | - | logger.Warn().Err(err).Msg("ffmpeg init failed, GPU probe skipped") | |
| 61 | - | return port.HWCapabilities{} | |
| 62 | - | } | |
| 63 | - | ||
| 64 | - | var profiles []hwProfile | |
| 65 | - | switch hwaccelPref { | |
| 66 | - | case hwAPINameVAAPI: | |
| 67 | - | profiles = []hwProfile{vaAPIProfile} | |
| 68 | - | case "nvenc": | |
| 69 | - | profiles = []hwProfile{nvencProfile} | |
| 70 | - | default: // "auto" or unrecognized | |
| 71 | - | profiles = []hwProfile{vaAPIProfile, nvencProfile} | |
| 72 | - | } | |
| 73 | - | ||
| 74 | - | for _, p := range profiles { | |
| 75 | - | caps := probeProfile(p, logger) | |
| 76 | - | if len(caps.Encoders) > 0 { | |
| 77 | - | return caps | |
| 78 | - | } | |
| 79 | - | } | |
| 80 | - | ||
| 81 | - | logger.Info().Msg("no usable GPU encoder found, transcoding will be unavailable") | |
| 82 | - | return port.HWCapabilities{} | |
| 83 | - | } | |
| 84 | - | ||
| 85 | - | // probeProfile attempts to open a hardware device and enumerate the | |
| 86 | - | // available encoders/decoders for a single API profile. | |
| 87 | - | func probeProfile(p hwProfile, logger *zerolog.Logger) port.HWCapabilities { | |
| 88 | - | deviceCtx, err := ffmpeg.HWDeviceCtxCreate(p.deviceType, "") | |
| 89 | - | if err != nil { | |
| 90 | - | logger.Debug(). | |
| 91 | - | Str("api", p.api). | |
| 92 | - | Err(err). | |
| 93 | - | Msg("hardware device not available") | |
| 94 | - | return port.HWCapabilities{} | |
| 95 | - | } | |
| 96 | - | // Free the probing device context; a long-lived one will be | |
| 97 | - | // created per transcode session (or shared across sessions). | |
| 98 | - | defer ffmpeg.BufferUnref(&deviceCtx) | |
| 99 | - | ||
| 100 | - | logger.Info(). | |
| 101 | - | Str("api", p.api). | |
| 102 | - | Msg("hardware device opened for probing") | |
| 103 | - | ||
| 104 | - | var encoders []string | |
| 105 | - | for _, name := range p.encoders { | |
| 106 | - | if ffmpeg.CodecFindEncoderByName(name) != nil { | |
| 107 | - | if isUsableEncoder(name) { | |
| 108 | - | encoders = append(encoders, name) | |
| 109 | - | logger.Info().Str("encoder", name).Msg("usable GPU encoder found") | |
| 110 | - | } else { | |
| 111 | - | logger.Debug().Str("encoder", name).Msg("encoder present but not usable (proprietary output)") | |
| 112 | - | } | |
| 113 | - | } | |
| 114 | - | } | |
| 115 | - | ||
| 116 | - | var decoders []string | |
| 117 | - | for _, name := range p.decoders { | |
| 118 | - | if ffmpeg.CodecFindDecoderByName(name) != nil { | |
| 119 | - | decoders = append(decoders, name) | |
| 120 | - | logger.Debug().Str("decoder", name).Msg("GPU decoder found") | |
| 121 | - | } | |
| 122 | - | } | |
| 123 | - | ||
| 124 | - | return port.HWCapabilities{ | |
| 125 | - | API: p.api, | |
| 126 | - | Device: "", // auto-detected by FFmpeg; reserved for future multi-GPU pinning | |
| 127 | - | Encoders: encoders, | |
| 128 | - | Decoders: decoders, | |
| 129 | - | } | |
| 130 | - | } | |
| @@ -1,130 +0,0 @@ | |||||
| 1 | - | //go:build !js || !wasm | |||
| 2 | - | ||||
| 3 | - | package transcoder | |||
| 4 | - | ||||
| 5 | - | import ( | |||
| 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 | - | const hwAPINameVAAPI = "vaapi" | |||
| 22 | - | ||||
| 23 | - | // isUsableEncoder returns true only for hardware encoders whose output | |||
| 24 | - | // codec is open and decodable by CEF (AV1, VP9). | |||
| 25 | - | func isUsableEncoder(name string) bool { | |||
| 26 | - | return usableEncoders[name] | |||
| 27 | - | } | |||
| 28 | - | ||||
| 29 | - | // hwProfile groups the encoder/decoder names to probe for a given API. | |||
| 30 | - | type hwProfile struct { | |||
| 31 | - | api string // "vaapi" or "nvenc" | |||
| 32 | - | deviceType int32 | |||
| 33 | - | encoders []string | |||
| 34 | - | decoders []string | |||
| 35 | - | } | |||
| 36 | - | ||||
| 37 | - | var vaAPIProfile = hwProfile{ | |||
| 38 | - | api: hwAPINameVAAPI, | |||
| 39 | - | deviceType: int32(ffmpeg.HwdeviceTypeVaapi), | |||
| 40 | - | encoders: []string{"av1_vaapi", "vp9_vaapi", "h264_vaapi"}, | |||
| 41 | - | decoders: []string{"h264_vaapi", "hevc_vaapi"}, | |||
| 42 | - | } | |||
| 43 | - | ||||
| 44 | - | var nvencProfile = hwProfile{ | |||
| 45 | - | api: "nvenc", | |||
| 46 | - | deviceType: int32(ffmpeg.HwdeviceTypeCuda), | |||
| 47 | - | // hevc_nvenc and h264_nvenc are probed for logging/diagnostic purposes | |||
| 48 | - | // but filtered as non-usable by isUsableEncoder (only open codecs like | |||
| 49 | - | // AV1 and VP9 are usable in CEF's minimal build). | |||
| 50 | - | encoders: []string{"av1_nvenc", "hevc_nvenc", "h264_nvenc"}, | |||
| 51 | - | decoders: []string{"h264_cuvid", "hevc_cuvid"}, | |||
| 52 | - | } | |||
| 53 | - | ||||
| 54 | - | // ProbeGPU detects available GPU hardware encoding capabilities. | |||
| 55 | - | // hwaccelPref selects which APIs to try: "auto" (VAAPI then CUDA), | |||
| 56 | - | // "vaapi" (VAAPI only), or "nvenc" (CUDA only). | |||
| 57 | - | // Returns empty HWCapabilities if no compatible GPU encoder is found. | |||
| 58 | - | func ProbeGPU(hwaccelPref string, logger *zerolog.Logger) port.HWCapabilities { | |||
| 59 | - | if err := ffmpeg.Init(); err != nil { | |||
| 60 | - | logger.Warn().Err(err).Msg("ffmpeg init failed, GPU probe skipped") | |||
| 61 | - | return port.HWCapabilities{} | |||
| 62 | - | } | |||
| 63 | - | ||||
| 64 | - | var profiles []hwProfile | |||
| 65 | - | switch hwaccelPref { | |||
| 66 | - | case hwAPINameVAAPI: | |||
| 67 | - | profiles = []hwProfile{vaAPIProfile} | |||
| 68 | - | case "nvenc": | |||
| 69 | - | profiles = []hwProfile{nvencProfile} | |||
| 70 | - | default: // "auto" or unrecognized | |||
| 71 | - | profiles = []hwProfile{vaAPIProfile, nvencProfile} | |||
| 72 | - | } | |||
| 73 | - | ||||
| 74 | - | for _, p := range profiles { | |||
| 75 | - | caps := probeProfile(p, logger) | |||
| 76 | - | if len(caps.Encoders) > 0 { | |||
| 77 | - | return caps | |||
| 78 | - | } | |||
| 79 | - | } | |||
| 80 | - | ||||
| 81 | - | logger.Info().Msg("no usable GPU encoder found, transcoding will be unavailable") | |||
| 82 | - | return port.HWCapabilities{} | |||
| 83 | - | } | |||
| 84 | - | ||||
| 85 | - | // probeProfile attempts to open a hardware device and enumerate the | |||
| 86 | - | // available encoders/decoders for a single API profile. | |||
| 87 | - | func probeProfile(p hwProfile, logger *zerolog.Logger) port.HWCapabilities { | |||
| 88 | - | deviceCtx, err := ffmpeg.HWDeviceCtxCreate(p.deviceType, "") | |||
| 89 | - | if err != nil { | |||
| 90 | - | logger.Debug(). | |||
| 91 | - | Str("api", p.api). | |||
| 92 | - | Err(err). | |||
| 93 | - | Msg("hardware device not available") | |||
| 94 | - | return port.HWCapabilities{} | |||
| 95 | - | } | |||
| 96 | - | // Free the probing device context; a long-lived one will be | |||
| 97 | - | // created per transcode session (or shared across sessions). | |||
| 98 | - | defer ffmpeg.BufferUnref(&deviceCtx) | |||
| 99 | - | ||||
| 100 | - | logger.Info(). | |||
| 101 | - | Str("api", p.api). | |||
| 102 | - | Msg("hardware device opened for probing") | |||
| 103 | - | ||||
| 104 | - | var encoders []string | |||
| 105 | - | for _, name := range p.encoders { | |||
| 106 | - | if ffmpeg.CodecFindEncoderByName(name) != nil { | |||
| 107 | - | if isUsableEncoder(name) { | |||
| 108 | - | encoders = append(encoders, name) | |||
| 109 | - | logger.Info().Str("encoder", name).Msg("usable GPU encoder found") | |||
| 110 | - | } else { | |||
| 111 | - | logger.Debug().Str("encoder", name).Msg("encoder present but not usable (proprietary output)") | |||
| 112 | - | } | |||
| 113 | - | } | |||
| 114 | - | } | |||
| 115 | - | ||||
| 116 | - | var decoders []string | |||
| 117 | - | for _, name := range p.decoders { | |||
| 118 | - | if ffmpeg.CodecFindDecoderByName(name) != nil { | |||
| 119 | - | decoders = append(decoders, name) | |||
| 120 | - | logger.Debug().Str("decoder", name).Msg("GPU decoder found") | |||
| 121 | - | } | |||
| 122 | - | } | |||
| 123 | - | ||||
| 124 | - | return port.HWCapabilities{ | |||
| 125 | - | API: p.api, | |||
| 126 | - | Device: "", // auto-detected by FFmpeg; reserved for future multi-GPU pinning | |||
| 127 | - | Encoders: encoders, | |||
| 128 | - | Decoders: decoders, | |||
| 129 | - | } | |||
| 130 | - | } | |||
D internal/infrastructure/transcoder/hwprobe_test.go +0 -41
| @@ -1,41 +0,0 @@ | |||
| 1 | - | //go:build !js || !wasm | |
| 2 | - | ||
| 3 | - | package transcoder | |
| 4 | - | ||
| 5 | - | import "testing" | |
| 6 | - | ||
| 7 | - | func TestIsUsableEncoder(t *testing.T) { | |
| 8 | - | tests := []struct { | |
| 9 | - | name string | |
| 10 | - | encoder string | |
| 11 | - | want bool | |
| 12 | - | }{ | |
| 13 | - | // Usable: open codec output that CEF can decode | |
| 14 | - | {name: "av1_vaapi", encoder: "av1_vaapi", want: true}, | |
| 15 | - | {name: "vp9_vaapi", encoder: "vp9_vaapi", want: true}, | |
| 16 | - | {name: "av1_nvenc", encoder: "av1_nvenc", want: true}, | |
| 17 | - | ||
| 18 | - | // Not usable: proprietary codec output CEF cannot decode | |
| 19 | - | {name: "h264_vaapi", encoder: "h264_vaapi", want: false}, | |
| 20 | - | {name: "hevc_nvenc", encoder: "hevc_nvenc", want: false}, | |
| 21 | - | {name: "h264_nvenc", encoder: "h264_nvenc", want: false}, | |
| 22 | - | ||
| 23 | - | // Software encoders are not usable either | |
| 24 | - | {name: "libx264", encoder: "libx264", want: false}, | |
| 25 | - | {name: "libsvtav1", encoder: "libsvtav1", want: false}, | |
| 26 | - | {name: "libvpx-vp9", encoder: "libvpx-vp9", want: false}, | |
| 27 | - | ||
| 28 | - | // Edge cases | |
| 29 | - | {name: "empty string", encoder: "", want: false}, | |
| 30 | - | {name: "random string", encoder: "not_a_codec", want: false}, | |
| 31 | - | } | |
| 32 | - | ||
| 33 | - | for _, tt := range tests { | |
| 34 | - | t.Run(tt.name, func(t *testing.T) { | |
| 35 | - | got := isUsableEncoder(tt.encoder) | |
| 36 | - | if got != tt.want { | |
| 37 | - | t.Errorf("isUsableEncoder(%q) = %v, want %v", tt.encoder, got, tt.want) | |
| 38 | - | } | |
| 39 | - | }) | |
| 40 | - | } | |
| 41 | - | } | |
| @@ -1,41 +0,0 @@ | |||||
| 1 | - | //go:build !js || !wasm | |||
| 2 | - | ||||
| 3 | - | package transcoder | |||
| 4 | - | ||||
| 5 | - | import "testing" | |||
| 6 | - | ||||
| 7 | - | func TestIsUsableEncoder(t *testing.T) { | |||
| 8 | - | tests := []struct { | |||
| 9 | - | name string | |||
| 10 | - | encoder string | |||
| 11 | - | want bool | |||
| 12 | - | }{ | |||
| 13 | - | // Usable: open codec output that CEF can decode | |||
| 14 | - | {name: "av1_vaapi", encoder: "av1_vaapi", want: true}, | |||
| 15 | - | {name: "vp9_vaapi", encoder: "vp9_vaapi", want: true}, | |||
| 16 | - | {name: "av1_nvenc", encoder: "av1_nvenc", want: true}, | |||
| 17 | - | ||||
| 18 | - | // Not usable: proprietary codec output CEF cannot decode | |||
| 19 | - | {name: "h264_vaapi", encoder: "h264_vaapi", want: false}, | |||
| 20 | - | {name: "hevc_nvenc", encoder: "hevc_nvenc", want: false}, | |||
| 21 | - | {name: "h264_nvenc", encoder: "h264_nvenc", want: false}, | |||
| 22 | - | ||||
| 23 | - | // Software encoders are not usable either | |||
| 24 | - | {name: "libx264", encoder: "libx264", want: false}, | |||
| 25 | - | {name: "libsvtav1", encoder: "libsvtav1", want: false}, | |||
| 26 | - | {name: "libvpx-vp9", encoder: "libvpx-vp9", want: false}, | |||
| 27 | - | ||||
| 28 | - | // Edge cases | |||
| 29 | - | {name: "empty string", encoder: "", want: false}, | |||
| 30 | - | {name: "random string", encoder: "not_a_codec", want: false}, | |||
| 31 | - | } | |||
| 32 | - | ||||
| 33 | - | for _, tt := range tests { | |||
| 34 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 35 | - | got := isUsableEncoder(tt.encoder) | |||
| 36 | - | if got != tt.want { | |||
| 37 | - | t.Errorf("isUsableEncoder(%q) = %v, want %v", tt.encoder, got, tt.want) | |||
| 38 | - | } | |||
| 39 | - | }) | |||
| 40 | - | } | |||
| 41 | - | } | |||
D internal/infrastructure/transcoder/pipeline.go +0 -920
| @@ -1,920 +0,0 @@ | |||
| 1 | - | //go:build !js || !wasm | |
| 2 | - | ||
| 3 | - | package transcoder | |
| 4 | - | ||
| 5 | - | import ( | |
| 6 | - | "context" | |
| 7 | - | "errors" | |
| 8 | - | "fmt" | |
| 9 | - | "io" | |
| 10 | - | "net" | |
| 11 | - | "net/http" | |
| 12 | - | "net/url" | |
| 13 | - | "strings" | |
| 14 | - | "time" | |
| 15 | - | "unsafe" | |
| 16 | - | ||
| 17 | - | "github.com/bnema/purego" | |
| 18 | - | "github.com/rs/zerolog" | |
| 19 | - | ||
| 20 | - | "github.com/bnema/purego-ffmpeg/ffmpeg" | |
| 21 | - | ||
| 22 | - | "github.com/bnema/dumber/internal/application/port" | |
| 23 | - | "github.com/bnema/dumber/internal/infrastructure/netguard" | |
| 24 | - | ) | |
| 25 | - | ||
| 26 | - | // avioBufSize is the buffer size for custom AVIO contexts (32 KiB). | |
| 27 | - | const avioBufSize = 32 * 1024 | |
| 28 | - | ||
| 29 | - | const ( | |
| 30 | - | vaapiInitialPoolSize = 20 | |
| 31 | - | minVideoBitrate = 500_000 | |
| 32 | - | ) | |
| 33 | - | ||
| 34 | - | // pipeline orchestrates a single GPU transcode session. It reads from | |
| 35 | - | // an HTTP source, decodes with a hardware decoder (or software fallback), | |
| 36 | - | // encodes video to an open codec (AV1/VP9) via GPU, encodes audio to | |
| 37 | - | // Opus via libopus, and muxes the result into WebM written to an | |
| 38 | - | // io.PipeWriter. | |
| 39 | - | type pipeline struct { | |
| 40 | - | sessionID string | |
| 41 | - | hwCaps port.HWCapabilities | |
| 42 | - | sourceURL string | |
| 43 | - | headers map[string]string | |
| 44 | - | quality string | |
| 45 | - | pw *io.PipeWriter | |
| 46 | - | logger zerolog.Logger | |
| 47 | - | ||
| 48 | - | // Prevent GC of callback closures and uintptrs. | |
| 49 | - | readCb uintptr | |
| 50 | - | writeCb uintptr | |
| 51 | - | } | |
| 52 | - | ||
| 53 | - | // httpClient is used for fetching source media. It has connect/TLS/header | |
| 54 | - | // timeouts but no overall Timeout so that long-lived streaming responses | |
| 55 | - | // are not killed. The session context handles cancellation. | |
| 56 | - | var httpClient = &http.Client{ | |
| 57 | - | Transport: &http.Transport{ | |
| 58 | - | DialContext: safeTranscodeDialContext, | |
| 59 | - | TLSHandshakeTimeout: 10 * time.Second, | |
| 60 | - | ResponseHeaderTimeout: 15 * time.Second, | |
| 61 | - | }, | |
| 62 | - | CheckRedirect: validateTranscodeRedirect, | |
| 63 | - | } | |
| 64 | - | ||
| 65 | - | func validateTranscodeRedirect(req *http.Request, via []*http.Request) error { | |
| 66 | - | if len(via) >= 10 { | |
| 67 | - | return fmt.Errorf("stopped after 10 redirects") | |
| 68 | - | } | |
| 69 | - | if err := validateHTTPTranscodeURL(req.Context(), req.URL); err != nil { | |
| 70 | - | return fmt.Errorf("blocked redirect target: %w", err) | |
| 71 | - | } | |
| 72 | - | return nil | |
| 73 | - | } | |
| 74 | - | ||
| 75 | - | func safeTranscodeDialContext(ctx context.Context, network, address string) (net.Conn, error) { | |
| 76 | - | host, portValue, err := net.SplitHostPort(address) | |
| 77 | - | if err != nil { | |
| 78 | - | return nil, err | |
| 79 | - | } | |
| 80 | - | ||
| 81 | - | ips, err := resolveAllowedTranscodeIPs(ctx, host) | |
| 82 | - | if err != nil { | |
| 83 | - | return nil, err | |
| 84 | - | } | |
| 85 | - | ||
| 86 | - | dialer := &net.Dialer{Timeout: 10 * time.Second} | |
| 87 | - | var lastErr error | |
| 88 | - | for _, ip := range ips { | |
| 89 | - | conn, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), portValue)) | |
| 90 | - | if dialErr == nil { | |
| 91 | - | return conn, nil | |
| 92 | - | } | |
| 93 | - | lastErr = dialErr | |
| 94 | - | } | |
| 95 | - | if lastErr != nil { | |
| 96 | - | return nil, lastErr | |
| 97 | - | } | |
| 98 | - | return nil, fmt.Errorf("no allowed transcode source addresses for %s", host) | |
| 99 | - | } | |
| 100 | - | ||
| 101 | - | func validateHTTPTranscodeURL(ctx context.Context, source *url.URL) error { | |
| 102 | - | if source == nil || source.Host == "" { | |
| 103 | - | return fmt.Errorf("invalid src") | |
| 104 | - | } | |
| 105 | - | if source.Scheme != "http" && source.Scheme != "https" { | |
| 106 | - | return fmt.Errorf("unsupported src scheme") | |
| 107 | - | } | |
| 108 | - | if IsStreamingManifestURL(source.String()) { | |
| 109 | - | return fmt.Errorf("streaming manifest transcoding is disabled") | |
| 110 | - | } | |
| 111 | - | _, err := resolveAllowedTranscodeIPs(ctx, source.Hostname()) | |
| 112 | - | return err | |
| 113 | - | } | |
| 114 | - | ||
| 115 | - | func resolveAllowedTranscodeIPs(ctx context.Context, host string) ([]net.IP, error) { | |
| 116 | - | host = strings.TrimSpace(host) | |
| 117 | - | if host == "" { | |
| 118 | - | return nil, fmt.Errorf("invalid src host") | |
| 119 | - | } | |
| 120 | - | if ip := net.ParseIP(host); ip != nil { | |
| 121 | - | if isBlockedTranscodeIP(ip) { | |
| 122 | - | return nil, fmt.Errorf("private src host not allowed") | |
| 123 | - | } | |
| 124 | - | return []net.IP{ip}, nil | |
| 125 | - | } | |
| 126 | - | ||
| 127 | - | lookupCtx, cancel := context.WithTimeout(ctx, 2*time.Second) | |
| 128 | - | defer cancel() | |
| 129 | - | addrs, err := net.DefaultResolver.LookupIPAddr(lookupCtx, host) | |
| 130 | - | if err != nil || len(addrs) == 0 { | |
| 131 | - | return nil, fmt.Errorf("src host could not be resolved") | |
| 132 | - | } | |
| 133 | - | ips := make([]net.IP, 0, len(addrs)) | |
| 134 | - | for _, addr := range addrs { | |
| 135 | - | if isBlockedTranscodeIP(addr.IP) { | |
| 136 | - | return nil, fmt.Errorf("private src host not allowed") | |
| 137 | - | } | |
| 138 | - | ips = append(ips, addr.IP) | |
| 139 | - | } | |
| 140 | - | return ips, nil | |
| 141 | - | } | |
| 142 | - | ||
| 143 | - | func isBlockedTranscodeIP(ip net.IP) bool { | |
| 144 | - | return netguard.IsBlockedTranscodeIP(ip) | |
| 145 | - | } | |
| 146 | - | ||
| 147 | - | func isStreamingManifestResponse(resp *http.Response) bool { | |
| 148 | - | if resp == nil { | |
| 149 | - | return false | |
| 150 | - | } | |
| 151 | - | if resp.Request != nil && resp.Request.URL != nil && IsStreamingManifestURL(resp.Request.URL.String()) { | |
| 152 | - | return true | |
| 153 | - | } | |
| 154 | - | contentType := strings.ToLower(resp.Header.Get("Content-Type")) | |
| 155 | - | return strings.Contains(contentType, "mpegurl") || strings.Contains(contentType, "dash+xml") | |
| 156 | - | } | |
| 157 | - | ||
| 158 | - | func newPipeline( | |
| 159 | - | sessionID string, | |
| 160 | - | hwCaps port.HWCapabilities, | |
| 161 | - | sourceURL string, | |
| 162 | - | headers map[string]string, | |
| 163 | - | quality string, | |
| 164 | - | pw *io.PipeWriter, | |
| 165 | - | logger zerolog.Logger, | |
| 166 | - | ) *pipeline { | |
| 167 | - | return &pipeline{ | |
| 168 | - | sessionID: sessionID, | |
| 169 | - | hwCaps: hwCaps, | |
| 170 | - | sourceURL: sourceURL, | |
| 171 | - | headers: headers, | |
| 172 | - | quality: quality, | |
| 173 | - | pw: pw, | |
| 174 | - | logger: logger, | |
| 175 | - | } | |
| 176 | - | } | |
| 177 | - | ||
| 178 | - | // run executes the full transcode pipeline in the calling goroutine. | |
| 179 | - | // It closes pw when done (with error or nil). | |
| 180 | - | func (p *pipeline) run(ctx context.Context) { | |
| 181 | - | err := p.doRun(ctx) | |
| 182 | - | if err != nil { | |
| 183 | - | log := p.logger.With(). | |
| 184 | - | Str("session_id", p.sessionID). | |
| 185 | - | Str("source_url", p.sourceURL). | |
| 186 | - | Logger() | |
| 187 | - | if errors.Is(err, context.Canceled) { | |
| 188 | - | log.Debug().Err(err).Msg("transcode pipeline canceled") | |
| 189 | - | } else { | |
| 190 | - | log.Error().Err(err).Msg("transcode pipeline failed") | |
| 191 | - | } | |
| 192 | - | _ = p.pw.CloseWithError(err) | |
| 193 | - | } else { | |
| 194 | - | p.logger.Debug(). | |
| 195 | - | Str("session_id", p.sessionID). | |
| 196 | - | Str("source_url", p.sourceURL). | |
| 197 | - | Msg("transcode pipeline completed") | |
| 198 | - | _ = p.pw.Close() | |
| 199 | - | } | |
| 200 | - | } | |
| 201 | - | ||
| 202 | - | // doRun contains the actual pipeline logic. Errors are returned, and | |
| 203 | - | // the caller (run) closes the pipe writer accordingly. | |
| 204 | - | func (p *pipeline) doRun(ctx context.Context) error { | |
| 205 | - | return p.doRunImpl(ctx) | |
| 206 | - | } | |
| 207 | - | ||
| 208 | - | //nolint:gocyclo,funlen,gosec // FFmpeg pipeline setup/teardown uses necessary branching and unsafe C-API calls. | |
| 209 | - | func (p *pipeline) doRunImpl(ctx context.Context) error { | |
| 210 | - | // --- Step 1-3: Open the input using the appropriate strategy --- | |
| 211 | - | inFmtCtx, closeInput, err := p.openInputFormatContext(ctx) | |
| 212 | - | if err != nil { | |
| 213 | - | return err | |
| 214 | - | } | |
| 215 | - | defer closeInput() | |
| 216 | - | ||
| 217 | - | // --- Step 4: Find stream info --- | |
| 218 | - | if ret := ffmpeg.FormatFindStreamInfo(inFmtCtx, nil); ret < 0 { | |
| 219 | - | return fmt.Errorf("find stream info: %d", ret) | |
| 220 | - | } | |
| 221 | - | ||
| 222 | - | // --- Step 5: Select input video/audio streams --- | |
| 223 | - | videoIdx, audioIdx, err := p.selectInputStreams(inFmtCtx) | |
| 224 | - | if err != nil { | |
| 225 | - | return err | |
| 226 | - | } | |
| 227 | - | ||
| 228 | - | videoStream := ffmpeg.FmtCtxStream(inFmtCtx, videoIdx) | |
| 229 | - | videoStreamWrap := ffmpeg.WrapStream(videoStream) | |
| 230 | - | videoCodecPar := videoStreamWrap.Codecpar() | |
| 231 | - | ||
| 232 | - | // --- Step 6: Open video decoder --- | |
| 233 | - | vidDecCtx, err := p.openVideoDecoder(videoCodecPar) | |
| 234 | - | if err != nil { | |
| 235 | - | return fmt.Errorf("open video decoder: %w", err) | |
| 236 | - | } | |
| 237 | - | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts. | |
| 238 | - | defer ffmpeg.CodecFreeContext(unsafe.Pointer(&vidDecCtx)) | |
| 239 | - | ||
| 240 | - | // --- Step 7-8: Open video encoder with HW accel --- | |
| 241 | - | hwDeviceCtx, vidEncCtx, err := p.openVideoEncoder(vidDecCtx) | |
| 242 | - | if err != nil { | |
| 243 | - | return fmt.Errorf("open video encoder: %w", err) | |
| 244 | - | } | |
| 245 | - | if hwDeviceCtx != nil { | |
| 246 | - | defer ffmpeg.BufferUnref(&hwDeviceCtx) | |
| 247 | - | } | |
| 248 | - | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts. | |
| 249 | - | defer ffmpeg.CodecFreeContext(unsafe.Pointer(&vidEncCtx)) | |
| 250 | - | ||
| 251 | - | // --- Step 9: Create output format context for WebM --- | |
| 252 | - | var outFmtCtx unsafe.Pointer | |
| 253 | - | //nolint:gosec // FFmpeg requires pointer-to-pointer allocation for output contexts. | |
| 254 | - | if ret := ffmpeg.FormatAllocOutputContext2(unsafe.Pointer(&outFmtCtx), nil, "webm", ""); ret < 0 { | |
| 255 | - | return fmt.Errorf("alloc output context: %d", ret) | |
| 256 | - | } | |
| 257 | - | defer ffmpeg.FormatFreeContext(outFmtCtx) | |
| 258 | - | ||
| 259 | - | // --- Step 10: Create write AVIO callback --- | |
| 260 | - | pw := p.pw | |
| 261 | - | writeFn := func(_ unsafe.Pointer, buf unsafe.Pointer, bufSize int32) int32 { | |
| 262 | - | if bufSize <= 0 { | |
| 263 | - | return 0 | |
| 264 | - | } | |
| 265 | - | //nolint:gosec // FFmpeg supplies a valid callback buffer and explicit byte count here. | |
| 266 | - | goBuf := unsafe.Slice((*byte)(buf), int(bufSize)) | |
| 267 | - | n, writeErr := pw.Write(goBuf) | |
| 268 | - | if writeErr != nil { | |
| 269 | - | return -1 | |
| 270 | - | } | |
| 271 | - | return int32(n) | |
| 272 | - | } | |
| 273 | - | p.writeCb = purego.NewCallback(writeFn) | |
| 274 | - | ||
| 275 | - | writeAVIO := ffmpeg.AvioAllocContext(avioBufSize, true, nil, 0, p.writeCb, 0) | |
| 276 | - | if writeAVIO == nil { | |
| 277 | - | return errors.New("failed to allocate write AVIO context") | |
| 278 | - | } | |
| 279 | - | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for AVIO contexts. | |
| 280 | - | defer ffmpeg.AvioContextFree(unsafe.Pointer(&writeAVIO)) | |
| 281 | - | ||
| 282 | - | ffmpeg.FmtCtxSetPB(outFmtCtx, writeAVIO) | |
| 283 | - | ffmpeg.FmtCtxSetFlags(outFmtCtx, ffmpeg.FmtCtxFlags(outFmtCtx)|ffmpeg.AVFMT_FLAG_CUSTOM_IO) | |
| 284 | - | ||
| 285 | - | // --- Step 11: Add video output stream --- | |
| 286 | - | outVideoStream := ffmpeg.FormatNewStream(outFmtCtx, nil) | |
| 287 | - | if outVideoStream == nil { | |
| 288 | - | return errors.New("failed to create output video stream") | |
| 289 | - | } | |
| 290 | - | outVideoStreamWrap := ffmpeg.WrapStream(outVideoStream) | |
| 291 | - | outVideoIdx := int(outVideoStreamWrap.Index()) | |
| 292 | - | ||
| 293 | - | if ret := ffmpeg.CodecParametersFromContext(outVideoStreamWrap.Codecpar(), vidEncCtx); ret < 0 { | |
| 294 | - | return fmt.Errorf("copy video encoder params to stream: %d", ret) | |
| 295 | - | } | |
| 296 | - | ||
| 297 | - | // --- Step 11b: Add audio output stream (if audio present) --- | |
| 298 | - | var audioTx *audioTranscoder | |
| 299 | - | if audioIdx >= 0 { | |
| 300 | - | audioStream := ffmpeg.FmtCtxStream(inFmtCtx, audioIdx) | |
| 301 | - | audioStreamWrap := ffmpeg.WrapStream(audioStream) | |
| 302 | - | audioCodecPar := audioStreamWrap.Codecpar() | |
| 303 | - | ||
| 304 | - | var txErr error | |
| 305 | - | audioTx, txErr = newAudioTranscoder(audioCodecPar, outFmtCtx, audioIdx) | |
| 306 | - | if txErr != nil { | |
| 307 | - | // Audio transcoding failure is non-fatal; we proceed with video only. | |
| 308 | - | audioTx = nil | |
| 309 | - | audioIdx = -1 | |
| 310 | - | } | |
| 311 | - | } | |
| 312 | - | // Use a closure so the defer sees the current value of audioTx at | |
| 313 | - | // cleanup time. This allows us to close and nil-out audioTx mid-loop | |
| 314 | - | // (e.g., on audio processing error) without double-closing. | |
| 315 | - | defer func() { | |
| 316 | - | if audioTx != nil { | |
| 317 | - | audioTx.close() | |
| 318 | - | } | |
| 319 | - | }() | |
| 320 | - | ||
| 321 | - | // --- Step 12: Write header --- | |
| 322 | - | if ret := ffmpeg.FormatWriteHeader(outFmtCtx, nil); ret < 0 { | |
| 323 | - | return fmt.Errorf("write header: %d", ret) | |
| 324 | - | } | |
| 325 | - | ||
| 326 | - | // --- Step 13: Main decode/encode loop --- | |
| 327 | - | pkt := ffmpeg.PacketAlloc() | |
| 328 | - | if pkt == nil { | |
| 329 | - | return errors.New("failed to allocate read packet") | |
| 330 | - | } | |
| 331 | - | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for packet allocations. | |
| 332 | - | defer ffmpeg.PacketFree(unsafe.Pointer(&pkt)) | |
| 333 | - | ||
| 334 | - | frame := ffmpeg.FrameAlloc() | |
| 335 | - | if frame == nil { | |
| 336 | - | return errors.New("failed to allocate decode frame") | |
| 337 | - | } | |
| 338 | - | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for frame allocations. | |
| 339 | - | defer ffmpeg.FrameFree(unsafe.Pointer(&frame)) | |
| 340 | - | ||
| 341 | - | encPkt := ffmpeg.PacketAlloc() | |
| 342 | - | if encPkt == nil { | |
| 343 | - | return errors.New("failed to allocate encode packet") | |
| 344 | - | } | |
| 345 | - | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for packet allocations. | |
| 346 | - | defer ffmpeg.PacketFree(unsafe.Pointer(&encPkt)) | |
| 347 | - | ||
| 348 | - | for { | |
| 349 | - | // Check context cancellation. | |
| 350 | - | select { | |
| 351 | - | case <-ctx.Done(): | |
| 352 | - | return ctx.Err() | |
| 353 | - | default: | |
| 354 | - | } | |
| 355 | - | ||
| 356 | - | ret := ffmpeg.ReadFrame(inFmtCtx, pkt) | |
| 357 | - | if ret < 0 { | |
| 358 | - | // EOF or error — break to flush. | |
| 359 | - | break | |
| 360 | - | } | |
| 361 | - | ||
| 362 | - | streamIdx := int(pktStreamIndex(pkt)) | |
| 363 | - | ||
| 364 | - | switch { | |
| 365 | - | case streamIdx == videoIdx: | |
| 366 | - | if err := p.transcodeVideoPacket(pkt, frame, encPkt, vidDecCtx, vidEncCtx, outFmtCtx, outVideoIdx); err != nil { | |
| 367 | - | ffmpeg.PacketUnref(pkt) | |
| 368 | - | return fmt.Errorf("video transcode: %w", err) | |
| 369 | - | } | |
| 370 | - | ||
| 371 | - | case audioTx != nil && streamIdx == audioIdx: | |
| 372 | - | if err := audioTx.processPacket(pkt, outFmtCtx); err != nil { | |
| 373 | - | // Audio errors are non-fatal: log warning, disable audio, | |
| 374 | - | // and continue with video-only output. This matches the | |
| 375 | - | // non-fatal handling of audio init failure above. | |
| 376 | - | p.logger.Warn(). | |
| 377 | - | Str("session_id", p.sessionID). | |
| 378 | - | Err(err). | |
| 379 | - | Msg("audio processing failed, continuing with video only") | |
| 380 | - | audioTx.close() | |
| 381 | - | audioTx = nil | |
| 382 | - | audioIdx = -1 | |
| 383 | - | } | |
| 384 | - | } | |
| 385 | - | ||
| 386 | - | ffmpeg.PacketUnref(pkt) | |
| 387 | - | } | |
| 388 | - | ||
| 389 | - | // --- Step 14: Flush decoders and encoders --- | |
| 390 | - | // Flush video decoder. | |
| 391 | - | ffmpeg.CodecSendPacket(vidDecCtx, nil) | |
| 392 | - | for { | |
| 393 | - | ret := ffmpeg.CodecReceiveFrame(vidDecCtx, frame) | |
| 394 | - | if ret < 0 { | |
| 395 | - | break | |
| 396 | - | } | |
| 397 | - | if err := p.encodeAndWriteVideoFrame(frame, encPkt, vidEncCtx, outFmtCtx, outVideoIdx); err != nil { | |
| 398 | - | ffmpeg.FrameUnref(frame) | |
| 399 | - | return fmt.Errorf("video flush: %w", err) | |
| 400 | - | } | |
| 401 | - | ffmpeg.FrameUnref(frame) | |
| 402 | - | } | |
| 403 | - | ||
| 404 | - | // Flush video encoder. | |
| 405 | - | ffmpeg.CodecSendFrame(vidEncCtx, nil) | |
| 406 | - | for { | |
| 407 | - | ret := ffmpeg.CodecReceivePacket(vidEncCtx, encPkt) | |
| 408 | - | if ret < 0 { | |
| 409 | - | break | |
| 410 | - | } | |
| 411 | - | pktSetStreamIndex(encPkt, int32(outVideoIdx)) | |
| 412 | - | outVidStream := ffmpeg.FmtCtxStream(outFmtCtx, outVideoIdx) | |
| 413 | - | outVidStreamWrap := ffmpeg.WrapStream(outVidStream) | |
| 414 | - | ffmpeg.PacketRescaleTs(encPkt, ffmpeg.CodecCtxTimeBase(vidEncCtx), outVidStreamWrap.TimeBase()) | |
| 415 | - | ffmpeg.InterleavedWriteFrame(outFmtCtx, encPkt) | |
| 416 | - | ffmpeg.PacketUnref(encPkt) | |
| 417 | - | } | |
| 418 | - | ||
| 419 | - | // Flush audio. | |
| 420 | - | if audioTx != nil { | |
| 421 | - | audioTx.flush(outFmtCtx, p.logger) | |
| 422 | - | } | |
| 423 | - | ||
| 424 | - | // --- Step 15: Write trailer --- | |
| 425 | - | if ret := ffmpeg.WriteTrailer(outFmtCtx); ret < 0 { | |
| 426 | - | return fmt.Errorf("write trailer: %d", ret) | |
| 427 | - | } | |
| 428 | - | ||
| 429 | - | return nil | |
| 430 | - | } | |
| 431 | - | ||
| 432 | - | func (p *pipeline) selectInputStreams(inFmtCtx unsafe.Pointer) (videoIdx, audioIdx int, err error) { | |
| 433 | - | videoIdx = -1 | |
| 434 | - | audioIdx = -1 | |
| 435 | - | bestVideoPixels := int64(-1) | |
| 436 | - | ||
| 437 | - | streamCount := int(ffmpeg.FmtCtxNbStreams(inFmtCtx)) | |
| 438 | - | for i := 0; i < streamCount; i++ { | |
| 439 | - | stream := ffmpeg.FmtCtxStream(inFmtCtx, i) | |
| 440 | - | if stream == nil { | |
| 441 | - | continue | |
| 442 | - | } | |
| 443 | - | streamWrap := ffmpeg.WrapStream(stream) | |
| 444 | - | codecPar := streamWrap.Codecpar() | |
| 445 | - | if codecPar == nil { | |
| 446 | - | continue | |
| 447 | - | } | |
| 448 | - | ||
| 449 | - | switch ffmpeg.CodecParCodecType(codecPar) { | |
| 450 | - | case ffmpeg.AVMEDIA_TYPE_VIDEO: | |
| 451 | - | width := int64(ffmpeg.CodecParWidth(codecPar)) | |
| 452 | - | height := int64(ffmpeg.CodecParHeight(codecPar)) | |
| 453 | - | pixels := width * height | |
| 454 | - | if videoIdx == -1 || pixels > bestVideoPixels { | |
| 455 | - | videoIdx = i | |
| 456 | - | bestVideoPixels = pixels | |
| 457 | - | } | |
| 458 | - | case ffmpeg.AVMEDIA_TYPE_AUDIO: | |
| 459 | - | if audioIdx == -1 { | |
| 460 | - | audioIdx = i | |
| 461 | - | } | |
| 462 | - | } | |
| 463 | - | } | |
| 464 | - | ||
| 465 | - | if videoIdx < 0 { | |
| 466 | - | return -1, audioIdx, errors.New("no video stream found in source") | |
| 467 | - | } | |
| 468 | - | ||
| 469 | - | videoCodecPar := ffmpeg.WrapStream(ffmpeg.FmtCtxStream(inFmtCtx, videoIdx)).Codecpar() | |
| 470 | - | log := p.logger.With(). | |
| 471 | - | Str("session_id", p.sessionID). | |
| 472 | - | Str("source_url", p.sourceURL). | |
| 473 | - | Int("video_stream_index", videoIdx). | |
| 474 | - | Int32("video_codec_id", ffmpeg.CodecParCodecID(videoCodecPar)). | |
| 475 | - | Int32("video_width", ffmpeg.CodecParWidth(videoCodecPar)). | |
| 476 | - | Int32("video_height", ffmpeg.CodecParHeight(videoCodecPar)). | |
| 477 | - | Logger() | |
| 478 | - | if audioIdx >= 0 { | |
| 479 | - | audioCodecPar := ffmpeg.WrapStream(ffmpeg.FmtCtxStream(inFmtCtx, audioIdx)).Codecpar() | |
| 480 | - | log.Info(). | |
| 481 | - | Int("audio_stream_index", audioIdx). | |
| 482 | - | Int32("audio_codec_id", ffmpeg.CodecParCodecID(audioCodecPar)). | |
| 483 | - | Msg("transcode pipeline selected input streams") | |
| 484 | - | } else { | |
| 485 | - | log.Info().Msg("transcode pipeline selected input streams") | |
| 486 | - | } | |
| 487 | - | ||
| 488 | - | return videoIdx, audioIdx, nil | |
| 489 | - | } | |
| 490 | - | ||
| 491 | - | func (p *pipeline) openInputFormatContext(ctx context.Context) (unsafe.Pointer, func(), error) { | |
| 492 | - | if IsStreamingManifestURL(p.sourceURL) { | |
| 493 | - | // FFmpeg resolves manifest redirects and segment URLs internally, bypassing | |
| 494 | - | // the guarded Go HTTP client below. Keep untrusted transcode requests on | |
| 495 | - | // custom I/O until manifests can be fetched and rewritten through the same | |
| 496 | - | // SSRF protections. | |
| 497 | - | return nil, nil, errors.New("streaming manifest transcoding is disabled") | |
| 498 | - | } | |
| 499 | - | return p.openCustomIOInputContext(ctx) | |
| 500 | - | } | |
| 501 | - | ||
| 502 | - | func (p *pipeline) openCustomIOInputContext(ctx context.Context) (unsafe.Pointer, func(), error) { | |
| 503 | - | req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.sourceURL, http.NoBody) | |
| 504 | - | if err != nil { | |
| 505 | - | return nil, nil, fmt.Errorf("create request: %w", err) | |
| 506 | - | } | |
| 507 | - | for k, v := range p.headers { | |
| 508 | - | req.Header.Set(k, v) | |
| 509 | - | } | |
| 510 | - | ||
| 511 | - | resp, err := httpClient.Do(req) //nolint:bodyclose // closed by returned cleanup after FFmpeg finishes reading. | |
| 512 | - | if err != nil { | |
| 513 | - | return nil, nil, fmt.Errorf("fetch source: %w", err) | |
| 514 | - | } | |
| 515 | - | ||
| 516 | - | if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent { | |
| 517 | - | _ = resp.Body.Close() | |
| 518 | - | return nil, nil, fmt.Errorf("source returned HTTP %d", resp.StatusCode) | |
| 519 | - | } | |
| 520 | - | if isStreamingManifestResponse(resp) { | |
| 521 | - | _ = resp.Body.Close() | |
| 522 | - | return nil, nil, errors.New("streaming manifest transcoding is disabled") | |
| 523 | - | } | |
| 524 | - | ||
| 525 | - | body := resp.Body | |
| 526 | - | readFn := func(_ unsafe.Pointer, buf unsafe.Pointer, bufSize int32) int32 { | |
| 527 | - | if bufSize <= 0 { | |
| 528 | - | return -1 | |
| 529 | - | } | |
| 530 | - | //nolint:gosec // FFmpeg supplies a valid callback buffer and explicit byte count here. | |
| 531 | - | goBuf := unsafe.Slice((*byte)(buf), int(bufSize)) | |
| 532 | - | // Retry up to 3 times for the edge case where body.Read returns | |
| 533 | - | // n=0, err=nil (allowed by the io.Reader contract but unusual). | |
| 534 | - | for tries := 0; tries < 3; tries++ { | |
| 535 | - | n, readErr := body.Read(goBuf) | |
| 536 | - | if n > 0 { | |
| 537 | - | return int32(n) | |
| 538 | - | } | |
| 539 | - | if readErr != nil { | |
| 540 | - | // Return AVERROR_EOF for EOF, generic error otherwise. | |
| 541 | - | return averrorEOF | |
| 542 | - | } | |
| 543 | - | // n == 0 && err == nil: retry | |
| 544 | - | } | |
| 545 | - | return averrorEOF | |
| 546 | - | } | |
| 547 | - | p.readCb = purego.NewCallback(readFn) | |
| 548 | - | ||
| 549 | - | readAVIO := ffmpeg.AvioAllocContext(avioBufSize, false, nil, p.readCb, 0, 0) | |
| 550 | - | if readAVIO == nil { | |
| 551 | - | _ = resp.Body.Close() | |
| 552 | - | return nil, nil, errors.New("failed to allocate read AVIO context") | |
| 553 | - | } | |
| 554 | - | ||
| 555 | - | inFmtCtx := ffmpeg.FormatAllocContext() | |
| 556 | - | if inFmtCtx == nil { | |
| 557 | - | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for AVIO contexts. | |
| 558 | - | ffmpeg.AvioContextFree(unsafe.Pointer(&readAVIO)) | |
| 559 | - | _ = resp.Body.Close() | |
| 560 | - | return nil, nil, errors.New("failed to allocate input format context") | |
| 561 | - | } | |
| 562 | - | ||
| 563 | - | ffmpeg.FmtCtxSetPB(inFmtCtx, readAVIO) | |
| 564 | - | ffmpeg.FmtCtxSetFlags(inFmtCtx, ffmpeg.FmtCtxFlags(inFmtCtx)|ffmpeg.AVFMT_FLAG_CUSTOM_IO) | |
| 565 | - | ||
| 566 | - | //nolint:gosec // FFmpeg requires pointer-to-pointer input opening for format contexts. | |
| 567 | - | if ret := ffmpeg.FormatOpenInput(unsafe.Pointer(&inFmtCtx), p.sourceURL, nil, nil); ret < 0 { | |
| 568 | - | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for AVIO contexts. | |
| 569 | - | ffmpeg.AvioContextFree(unsafe.Pointer(&readAVIO)) | |
| 570 | - | _ = resp.Body.Close() | |
| 571 | - | ffmpeg.FormatFreeContext(inFmtCtx) | |
| 572 | - | return nil, nil, fmt.Errorf("open input: %d", ret) | |
| 573 | - | } | |
| 574 | - | ||
| 575 | - | cleanup := func() { | |
| 576 | - | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for format contexts. | |
| 577 | - | ffmpeg.FormatCloseInput(unsafe.Pointer(&inFmtCtx)) | |
| 578 | - | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for AVIO contexts. | |
| 579 | - | ffmpeg.AvioContextFree(unsafe.Pointer(&readAVIO)) | |
| 580 | - | _ = resp.Body.Close() | |
| 581 | - | } | |
| 582 | - | return inFmtCtx, cleanup, nil | |
| 583 | - | } | |
| 584 | - | ||
| 585 | - | func (p *pipeline) openVideoDecoder(codecPar unsafe.Pointer) (unsafe.Pointer, error) { | |
| 586 | - | codecID := ffmpeg.CodecParCodecID(codecPar) | |
| 587 | - | ||
| 588 | - | // Try HW decoder first. | |
| 589 | - | var dec unsafe.Pointer | |
| 590 | - | for _, name := range p.hwCaps.Decoders { | |
| 591 | - | dec = ffmpeg.CodecFindDecoderByName(name) | |
| 592 | - | if dec != nil { | |
| 593 | - | break | |
| 594 | - | } | |
| 595 | - | } | |
| 596 | - | ||
| 597 | - | // Fallback to software decoder. | |
| 598 | - | if dec == nil { | |
| 599 | - | dec = ffmpeg.CodecFindDecoder(codecID) | |
| 600 | - | } | |
| 601 | - | if dec == nil { | |
| 602 | - | return nil, fmt.Errorf("no decoder found for codec ID %d", codecID) | |
| 603 | - | } | |
| 604 | - | ||
| 605 | - | decCtx := ffmpeg.CodecAllocContext3(dec) | |
| 606 | - | if decCtx == nil { | |
| 607 | - | return nil, errors.New("failed to allocate video decoder context") | |
| 608 | - | } | |
| 609 | - | ||
| 610 | - | if ret := ffmpeg.CodecParametersToContext(decCtx, codecPar); ret < 0 { | |
| 611 | - | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts. | |
| 612 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx)) | |
| 613 | - | return nil, fmt.Errorf("copy video codec params: %d", ret) | |
| 614 | - | } | |
| 615 | - | ||
| 616 | - | if ret := ffmpeg.CodecOpen2(decCtx, dec, nil); ret < 0 { | |
| 617 | - | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts. | |
| 618 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx)) | |
| 619 | - | return nil, fmt.Errorf("open video decoder: %d", ret) | |
| 620 | - | } | |
| 621 | - | ||
| 622 | - | return decCtx, nil | |
| 623 | - | } | |
| 624 | - | ||
| 625 | - | // openVideoEncoder creates a hardware device context and encoder for | |
| 626 | - | // the video stream. It uses the first available encoder from hwCaps.Encoders. | |
| 627 | - | func (p *pipeline) openVideoEncoder(decCtx unsafe.Pointer) (hwDeviceCtx, encCtx unsafe.Pointer, err error) { | |
| 628 | - | if len(p.hwCaps.Encoders) == 0 { | |
| 629 | - | return nil, nil, errors.New("no GPU encoders available") | |
| 630 | - | } | |
| 631 | - | ||
| 632 | - | // Create the HW device context. | |
| 633 | - | var deviceType int32 | |
| 634 | - | switch p.hwCaps.API { | |
| 635 | - | case hwAPINameVAAPI: | |
| 636 | - | deviceType = int32(ffmpeg.HwdeviceTypeVaapi) | |
| 637 | - | case "nvenc": | |
| 638 | - | deviceType = int32(ffmpeg.HwdeviceTypeCuda) | |
| 639 | - | default: | |
| 640 | - | return nil, nil, fmt.Errorf("unknown HW API: %s", p.hwCaps.API) | |
| 641 | - | } | |
| 642 | - | ||
| 643 | - | hwDeviceCtx, hwErr := ffmpeg.HWDeviceCtxCreate(deviceType, p.hwCaps.Device) | |
| 644 | - | if hwErr != nil { | |
| 645 | - | return nil, nil, fmt.Errorf("create HW device: %w", hwErr) | |
| 646 | - | } | |
| 647 | - | ||
| 648 | - | width := ffmpeg.CodecCtxWidth(decCtx) | |
| 649 | - | height := ffmpeg.CodecCtxHeight(decCtx) | |
| 650 | - | ||
| 651 | - | var lastErr error | |
| 652 | - | for _, name := range p.hwCaps.Encoders { | |
| 653 | - | enc := ffmpeg.CodecFindEncoderByName(name) | |
| 654 | - | if enc == nil { | |
| 655 | - | continue | |
| 656 | - | } | |
| 657 | - | ||
| 658 | - | encCtx, err = p.tryOpenVideoEncoder(decCtx, hwDeviceCtx, name, enc) | |
| 659 | - | if err == nil { | |
| 660 | - | p.logger.Info(). | |
| 661 | - | Str("session_id", p.sessionID). | |
| 662 | - | Str("source_url", p.sourceURL). | |
| 663 | - | Str("encoder", name). | |
| 664 | - | Str("api", p.hwCaps.API). | |
| 665 | - | Int32("width", width). | |
| 666 | - | Int32("height", height). | |
| 667 | - | Msg("transcode pipeline selected video encoder") | |
| 668 | - | return hwDeviceCtx, encCtx, nil | |
| 669 | - | } | |
| 670 | - | lastErr = err | |
| 671 | - | p.logger.Warn(). | |
| 672 | - | Str("session_id", p.sessionID). | |
| 673 | - | Str("source_url", p.sourceURL). | |
| 674 | - | Str("encoder", name). | |
| 675 | - | Err(err). | |
| 676 | - | Msg("transcode pipeline encoder candidate failed") | |
| 677 | - | } | |
| 678 | - | ||
| 679 | - | ffmpeg.BufferUnref(&hwDeviceCtx) | |
| 680 | - | if lastErr != nil { | |
| 681 | - | return nil, nil, fmt.Errorf("no usable encoder codec found: %w", lastErr) | |
| 682 | - | } | |
| 683 | - | return nil, nil, errors.New("no usable encoder codec found") | |
| 684 | - | } | |
| 685 | - | ||
| 686 | - | func (p *pipeline) tryOpenVideoEncoder(decCtx, hwDeviceCtx unsafe.Pointer, encoderName string, enc unsafe.Pointer) (unsafe.Pointer, error) { | |
| 687 | - | encCtx := ffmpeg.CodecAllocContext3(enc) | |
| 688 | - | if encCtx == nil { | |
| 689 | - | return nil, errors.New("failed to allocate video encoder context") | |
| 690 | - | } | |
| 691 | - | ||
| 692 | - | width := ffmpeg.CodecCtxWidth(decCtx) | |
| 693 | - | height := ffmpeg.CodecCtxHeight(decCtx) | |
| 694 | - | ffmpeg.CodecCtxSetWidth(encCtx, width) | |
| 695 | - | ffmpeg.CodecCtxSetHeight(encCtx, height) | |
| 696 | - | ||
| 697 | - | framerate := ffmpeg.CodecCtxFramerate(decCtx) | |
| 698 | - | if framerate.Num == 0 { | |
| 699 | - | framerate = ffmpeg.AVRational{Num: 30, Den: 1} | |
| 700 | - | } | |
| 701 | - | ffmpeg.CodecCtxSetFramerate(encCtx, framerate) | |
| 702 | - | ffmpeg.CodecCtxSetTimeBase(encCtx, ffmpeg.AVRational{Num: framerate.Den, Den: framerate.Num}) | |
| 703 | - | ||
| 704 | - | if p.hwCaps.API == hwAPINameVAAPI { | |
| 705 | - | ffmpeg.CodecCtxSetPixFmt(encCtx, int32(ffmpeg.PixFmtVaapi)) | |
| 706 | - | } else { | |
| 707 | - | ffmpeg.CodecCtxSetPixFmt(encCtx, int32(ffmpeg.PixFmtNv12)) | |
| 708 | - | } | |
| 709 | - | ||
| 710 | - | gopSize := framerate.Num / framerate.Den * 2 | |
| 711 | - | if gopSize <= 0 { | |
| 712 | - | gopSize = 60 | |
| 713 | - | } | |
| 714 | - | codecCtxSetGopSize(encCtx, gopSize) | |
| 715 | - | p.applyQualityPreset(encCtx, encoderName, width, height) | |
| 716 | - | ||
| 717 | - | hwDeviceRef := ffmpeg.BufferRef(hwDeviceCtx) | |
| 718 | - | if hwDeviceRef == nil { | |
| 719 | - | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts. | |
| 720 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx)) | |
| 721 | - | return nil, errors.New("failed to ref HW device context for encoder") | |
| 722 | - | } | |
| 723 | - | ffmpeg.CodecCtxSetHwDeviceCtx(encCtx, hwDeviceRef) | |
| 724 | - | ||
| 725 | - | if p.hwCaps.API == hwAPINameVAAPI { | |
| 726 | - | framesRef := ffmpeg.HWFrameCtxAlloc(hwDeviceCtx) | |
| 727 | - | if framesRef == nil { | |
| 728 | - | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts. | |
| 729 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx)) | |
| 730 | - | return nil, errors.New("failed to allocate HW frames context") | |
| 731 | - | } | |
| 732 | - | ||
| 733 | - | hwFramesData := ffmpeg.BufferRefData(framesRef) | |
| 734 | - | if hwFramesData == nil { | |
| 735 | - | ffmpeg.BufferUnref(&framesRef) | |
| 736 | - | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts. | |
| 737 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx)) | |
| 738 | - | return nil, errors.New("failed to access HW frames context payload") | |
| 739 | - | } | |
| 740 | - | ||
| 741 | - | ffmpeg.HWFramesCtxSetInitialPoolSize(hwFramesData, vaapiInitialPoolSize) | |
| 742 | - | ffmpeg.HWFramesCtxSetFormat(hwFramesData, int32(ffmpeg.PixFmtVaapi)) | |
| 743 | - | ffmpeg.HWFramesCtxSetSWFormat(hwFramesData, int32(ffmpeg.PixFmtNv12)) | |
| 744 | - | ffmpeg.HWFramesCtxSetWidth(hwFramesData, width) | |
| 745 | - | ffmpeg.HWFramesCtxSetHeight(hwFramesData, height) | |
| 746 | - | ||
| 747 | - | if ret := ffmpeg.HWFrameCtxInit(framesRef); ret < 0 { | |
| 748 | - | ffmpeg.BufferUnref(&framesRef) | |
| 749 | - | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts. | |
| 750 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx)) | |
| 751 | - | return nil, fmt.Errorf("init HW frames context: %d", ret) | |
| 752 | - | } | |
| 753 | - | ||
| 754 | - | ffmpeg.CodecCtxSetHwFramesCtx(encCtx, framesRef) | |
| 755 | - | } | |
| 756 | - | ||
| 757 | - | if ret := ffmpeg.CodecOpen2(encCtx, enc, nil); ret < 0 { | |
| 758 | - | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts. | |
| 759 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx)) | |
| 760 | - | return nil, fmt.Errorf("open video encoder %s: %d", encoderName, ret) | |
| 761 | - | } | |
| 762 | - | ||
| 763 | - | return encCtx, nil | |
| 764 | - | } | |
| 765 | - | ||
| 766 | - | // applyQualityPreset sets bitrate and encoder options based on the | |
| 767 | - | // quality string ("low", "medium", "high"). | |
| 768 | - | func (p *pipeline) applyQualityPreset(encCtx unsafe.Pointer, encoderName string, width, height int32) { | |
| 769 | - | pixels := int64(width) * int64(height) | |
| 770 | - | ||
| 771 | - | // Base bitrate scaled to resolution (targeting 1080p baseline). | |
| 772 | - | // low: ~1.5 Mbps at 1080p, medium: ~3 Mbps, high: ~6 Mbps. | |
| 773 | - | var bitsPerPixel float64 | |
| 774 | - | switch p.quality { | |
| 775 | - | case "low": | |
| 776 | - | bitsPerPixel = 0.07 | |
| 777 | - | case "high": | |
| 778 | - | bitsPerPixel = 0.30 | |
| 779 | - | default: // "medium" | |
| 780 | - | bitsPerPixel = 0.15 | |
| 781 | - | } | |
| 782 | - | ||
| 783 | - | bitrate := int64(float64(pixels) * bitsPerPixel) | |
| 784 | - | if bitrate < minVideoBitrate { | |
| 785 | - | bitrate = minVideoBitrate // floor at 500 kbps | |
| 786 | - | } | |
| 787 | - | codecCtxSetBitRate(encCtx, bitrate) | |
| 788 | - | ||
| 789 | - | // Set encoder-specific options via av_opt_set. | |
| 790 | - | if strings.HasSuffix(encoderName, "_vaapi") { | |
| 791 | - | // VAAPI encoders use rc_mode for rate control. | |
| 792 | - | ffmpeg.AVOptSet(encCtx, "rc_mode", "VBR", 0) | |
| 793 | - | } else if strings.HasSuffix(encoderName, "_nvenc") { | |
| 794 | - | // NVENC uses preset and rc. | |
| 795 | - | switch p.quality { | |
| 796 | - | case "low": | |
| 797 | - | ffmpeg.AVOptSet(encCtx, "preset", "p4", 0) | |
| 798 | - | case "high": | |
| 799 | - | ffmpeg.AVOptSet(encCtx, "preset", "p7", 0) | |
| 800 | - | default: | |
| 801 | - | ffmpeg.AVOptSet(encCtx, "preset", "p5", 0) | |
| 802 | - | } | |
| 803 | - | ffmpeg.AVOptSet(encCtx, "rc", "vbr", 0) | |
| 804 | - | } | |
| 805 | - | } | |
| 806 | - | ||
| 807 | - | // transcodeVideoPacket decodes a video packet, encodes the resulting | |
| 808 | - | // frames, and writes them to the output. | |
| 809 | - | func (p *pipeline) transcodeVideoPacket(pkt, frame, encPkt, decCtx, encCtx, outFmtCtx unsafe.Pointer, outVideoIdx int) error { | |
| 810 | - | ret := ffmpeg.CodecSendPacket(decCtx, pkt) | |
| 811 | - | if ret < 0 { | |
| 812 | - | return fmt.Errorf("video decode send packet: %d", ret) | |
| 813 | - | } | |
| 814 | - | ||
| 815 | - | for { | |
| 816 | - | ret = ffmpeg.CodecReceiveFrame(decCtx, frame) | |
| 817 | - | if ret < 0 { | |
| 818 | - | // EAGAIN or EOF. | |
| 819 | - | break | |
| 820 | - | } | |
| 821 | - | ||
| 822 | - | if err := p.encodeAndWriteVideoFrame(frame, encPkt, encCtx, outFmtCtx, outVideoIdx); err != nil { | |
| 823 | - | ffmpeg.FrameUnref(frame) | |
| 824 | - | return err | |
| 825 | - | } | |
| 826 | - | ffmpeg.FrameUnref(frame) | |
| 827 | - | } | |
| 828 | - | ||
| 829 | - | return nil | |
| 830 | - | } | |
| 831 | - | ||
| 832 | - | // encodeAndWriteVideoFrame sends a decoded frame to the encoder and | |
| 833 | - | // writes any resulting packets to the output. | |
| 834 | - | func (p *pipeline) encodeAndWriteVideoFrame(frame, encPkt, encCtx, outFmtCtx unsafe.Pointer, outVideoIdx int) error { | |
| 835 | - | encFrame := frame | |
| 836 | - | var cleanup func() | |
| 837 | - | if p.hwCaps.API == hwAPINameVAAPI { | |
| 838 | - | var err error | |
| 839 | - | encFrame, cleanup, err = uploadVideoFrameToVAAPI(frame, encCtx) | |
| 840 | - | if err != nil { | |
| 841 | - | return err | |
| 842 | - | } | |
| 843 | - | defer cleanup() | |
| 844 | - | } | |
| 845 | - | ||
| 846 | - | ret := ffmpeg.CodecSendFrame(encCtx, encFrame) | |
| 847 | - | if ret < 0 { | |
| 848 | - | return fmt.Errorf("video encode send frame: %d", ret) | |
| 849 | - | } | |
| 850 | - | ||
| 851 | - | for { | |
| 852 | - | ret = ffmpeg.CodecReceivePacket(encCtx, encPkt) | |
| 853 | - | if ret < 0 { | |
| 854 | - | break | |
| 855 | - | } | |
| 856 | - | ||
| 857 | - | pktSetStreamIndex(encPkt, int32(outVideoIdx)) | |
| 858 | - | ||
| 859 | - | outVidStream := ffmpeg.FmtCtxStream(outFmtCtx, outVideoIdx) | |
| 860 | - | outVidStreamWrap := ffmpeg.WrapStream(outVidStream) | |
| 861 | - | ffmpeg.PacketRescaleTs(encPkt, ffmpeg.CodecCtxTimeBase(encCtx), outVidStreamWrap.TimeBase()) | |
| 862 | - | ||
| 863 | - | if wret := ffmpeg.InterleavedWriteFrame(outFmtCtx, encPkt); wret < 0 { | |
| 864 | - | ffmpeg.PacketUnref(encPkt) | |
| 865 | - | return fmt.Errorf("video write frame: %d", wret) | |
| 866 | - | } | |
| 867 | - | ffmpeg.PacketUnref(encPkt) | |
| 868 | - | } | |
| 869 | - | ||
| 870 | - | return nil | |
| 871 | - | } | |
| 872 | - | ||
| 873 | - | func uploadVideoFrameToVAAPI(frame, encCtx unsafe.Pointer) (unsafe.Pointer, func(), error) { | |
| 874 | - | if frameFormat(frame) == int32(ffmpeg.PixFmtVaapi) { | |
| 875 | - | return frame, func() {}, nil | |
| 876 | - | } | |
| 877 | - | ||
| 878 | - | hwFramesCtx := ffmpeg.CodecCtxHwFramesCtx(encCtx) | |
| 879 | - | if hwFramesCtx == nil { | |
| 880 | - | return nil, nil, errors.New("video encoder missing hw_frames_ctx") | |
| 881 | - | } | |
| 882 | - | ||
| 883 | - | hwFrame := ffmpeg.FrameAlloc() | |
| 884 | - | if hwFrame == nil { | |
| 885 | - | return nil, nil, errors.New("failed to allocate VAAPI frame") | |
| 886 | - | } | |
| 887 | - | ||
| 888 | - | cleanup := func() { | |
| 889 | - | if hwFrame != nil { | |
| 890 | - | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for frame allocations. | |
| 891 | - | ffmpeg.FrameFree(unsafe.Pointer(&hwFrame)) | |
| 892 | - | } | |
| 893 | - | } | |
| 894 | - | ||
| 895 | - | frameSetFormat(hwFrame, int32(ffmpeg.PixFmtVaapi)) | |
| 896 | - | frameSetWidth(hwFrame, frameWidth(frame)) | |
| 897 | - | frameSetHeight(hwFrame, frameHeight(frame)) | |
| 898 | - | frameSetPts(hwFrame, framePts(frame)) | |
| 899 | - | ||
| 900 | - | hwFramesRef := ffmpeg.BufferRef(hwFramesCtx) | |
| 901 | - | if hwFramesRef == nil { | |
| 902 | - | cleanup() | |
| 903 | - | return nil, nil, errors.New("failed to ref encoder hw frames context") | |
| 904 | - | } | |
| 905 | - | frameSetHWFramesCtx(hwFrame, hwFramesRef) | |
| 906 | - | ||
| 907 | - | if ret := ffmpeg.HWFrameGetBuffer(hwFramesCtx, hwFrame, 0); ret < 0 { | |
| 908 | - | cleanup() | |
| 909 | - | return nil, nil, fmt.Errorf("allocate VAAPI frame buffer: %d", ret) | |
| 910 | - | } | |
| 911 | - | if ret := ffmpeg.HWFrameTransferData(hwFrame, frame, 0); ret < 0 { | |
| 912 | - | cleanup() | |
| 913 | - | return nil, nil, fmt.Errorf("upload frame to VAAPI: %d", ret) | |
| 914 | - | } | |
| 915 | - | ||
| 916 | - | return hwFrame, cleanup, nil | |
| 917 | - | } | |
| 918 | - | ||
| 919 | - | // averrorEOF is the AVERROR_EOF constant. | |
| 920 | - | const averrorEOF = -('E' | 'O'<<8 | 'F'<<16 | ' '<<24) | |
| @@ -1,920 +0,0 @@ | |||||
| 1 | - | //go:build !js || !wasm | |||
| 2 | - | ||||
| 3 | - | package transcoder | |||
| 4 | - | ||||
| 5 | - | import ( | |||
| 6 | - | "context" | |||
| 7 | - | "errors" | |||
| 8 | - | "fmt" | |||
| 9 | - | "io" | |||
| 10 | - | "net" | |||
| 11 | - | "net/http" | |||
| 12 | - | "net/url" | |||
| 13 | - | "strings" | |||
| 14 | - | "time" | |||
| 15 | - | "unsafe" | |||
| 16 | - | ||||
| 17 | - | "github.com/bnema/purego" | |||
| 18 | - | "github.com/rs/zerolog" | |||
| 19 | - | ||||
| 20 | - | "github.com/bnema/purego-ffmpeg/ffmpeg" | |||
| 21 | - | ||||
| 22 | - | "github.com/bnema/dumber/internal/application/port" | |||
| 23 | - | "github.com/bnema/dumber/internal/infrastructure/netguard" | |||
| 24 | - | ) | |||
| 25 | - | ||||
| 26 | - | // avioBufSize is the buffer size for custom AVIO contexts (32 KiB). | |||
| 27 | - | const avioBufSize = 32 * 1024 | |||
| 28 | - | ||||
| 29 | - | const ( | |||
| 30 | - | vaapiInitialPoolSize = 20 | |||
| 31 | - | minVideoBitrate = 500_000 | |||
| 32 | - | ) | |||
| 33 | - | ||||
| 34 | - | // pipeline orchestrates a single GPU transcode session. It reads from | |||
| 35 | - | // an HTTP source, decodes with a hardware decoder (or software fallback), | |||
| 36 | - | // encodes video to an open codec (AV1/VP9) via GPU, encodes audio to | |||
| 37 | - | // Opus via libopus, and muxes the result into WebM written to an | |||
| 38 | - | // io.PipeWriter. | |||
| 39 | - | type pipeline struct { | |||
| 40 | - | sessionID string | |||
| 41 | - | hwCaps port.HWCapabilities | |||
| 42 | - | sourceURL string | |||
| 43 | - | headers map[string]string | |||
| 44 | - | quality string | |||
| 45 | - | pw *io.PipeWriter | |||
| 46 | - | logger zerolog.Logger | |||
| 47 | - | ||||
| 48 | - | // Prevent GC of callback closures and uintptrs. | |||
| 49 | - | readCb uintptr | |||
| 50 | - | writeCb uintptr | |||
| 51 | - | } | |||
| 52 | - | ||||
| 53 | - | // httpClient is used for fetching source media. It has connect/TLS/header | |||
| 54 | - | // timeouts but no overall Timeout so that long-lived streaming responses | |||
| 55 | - | // are not killed. The session context handles cancellation. | |||
| 56 | - | var httpClient = &http.Client{ | |||
| 57 | - | Transport: &http.Transport{ | |||
| 58 | - | DialContext: safeTranscodeDialContext, | |||
| 59 | - | TLSHandshakeTimeout: 10 * time.Second, | |||
| 60 | - | ResponseHeaderTimeout: 15 * time.Second, | |||
| 61 | - | }, | |||
| 62 | - | CheckRedirect: validateTranscodeRedirect, | |||
| 63 | - | } | |||
| 64 | - | ||||
| 65 | - | func validateTranscodeRedirect(req *http.Request, via []*http.Request) error { | |||
| 66 | - | if len(via) >= 10 { | |||
| 67 | - | return fmt.Errorf("stopped after 10 redirects") | |||
| 68 | - | } | |||
| 69 | - | if err := validateHTTPTranscodeURL(req.Context(), req.URL); err != nil { | |||
| 70 | - | return fmt.Errorf("blocked redirect target: %w", err) | |||
| 71 | - | } | |||
| 72 | - | return nil | |||
| 73 | - | } | |||
| 74 | - | ||||
| 75 | - | func safeTranscodeDialContext(ctx context.Context, network, address string) (net.Conn, error) { | |||
| 76 | - | host, portValue, err := net.SplitHostPort(address) | |||
| 77 | - | if err != nil { | |||
| 78 | - | return nil, err | |||
| 79 | - | } | |||
| 80 | - | ||||
| 81 | - | ips, err := resolveAllowedTranscodeIPs(ctx, host) | |||
| 82 | - | if err != nil { | |||
| 83 | - | return nil, err | |||
| 84 | - | } | |||
| 85 | - | ||||
| 86 | - | dialer := &net.Dialer{Timeout: 10 * time.Second} | |||
| 87 | - | var lastErr error | |||
| 88 | - | for _, ip := range ips { | |||
| 89 | - | conn, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), portValue)) | |||
| 90 | - | if dialErr == nil { | |||
| 91 | - | return conn, nil | |||
| 92 | - | } | |||
| 93 | - | lastErr = dialErr | |||
| 94 | - | } | |||
| 95 | - | if lastErr != nil { | |||
| 96 | - | return nil, lastErr | |||
| 97 | - | } | |||
| 98 | - | return nil, fmt.Errorf("no allowed transcode source addresses for %s", host) | |||
| 99 | - | } | |||
| 100 | - | ||||
| 101 | - | func validateHTTPTranscodeURL(ctx context.Context, source *url.URL) error { | |||
| 102 | - | if source == nil || source.Host == "" { | |||
| 103 | - | return fmt.Errorf("invalid src") | |||
| 104 | - | } | |||
| 105 | - | if source.Scheme != "http" && source.Scheme != "https" { | |||
| 106 | - | return fmt.Errorf("unsupported src scheme") | |||
| 107 | - | } | |||
| 108 | - | if IsStreamingManifestURL(source.String()) { | |||
| 109 | - | return fmt.Errorf("streaming manifest transcoding is disabled") | |||
| 110 | - | } | |||
| 111 | - | _, err := resolveAllowedTranscodeIPs(ctx, source.Hostname()) | |||
| 112 | - | return err | |||
| 113 | - | } | |||
| 114 | - | ||||
| 115 | - | func resolveAllowedTranscodeIPs(ctx context.Context, host string) ([]net.IP, error) { | |||
| 116 | - | host = strings.TrimSpace(host) | |||
| 117 | - | if host == "" { | |||
| 118 | - | return nil, fmt.Errorf("invalid src host") | |||
| 119 | - | } | |||
| 120 | - | if ip := net.ParseIP(host); ip != nil { | |||
| 121 | - | if isBlockedTranscodeIP(ip) { | |||
| 122 | - | return nil, fmt.Errorf("private src host not allowed") | |||
| 123 | - | } | |||
| 124 | - | return []net.IP{ip}, nil | |||
| 125 | - | } | |||
| 126 | - | ||||
| 127 | - | lookupCtx, cancel := context.WithTimeout(ctx, 2*time.Second) | |||
| 128 | - | defer cancel() | |||
| 129 | - | addrs, err := net.DefaultResolver.LookupIPAddr(lookupCtx, host) | |||
| 130 | - | if err != nil || len(addrs) == 0 { | |||
| 131 | - | return nil, fmt.Errorf("src host could not be resolved") | |||
| 132 | - | } | |||
| 133 | - | ips := make([]net.IP, 0, len(addrs)) | |||
| 134 | - | for _, addr := range addrs { | |||
| 135 | - | if isBlockedTranscodeIP(addr.IP) { | |||
| 136 | - | return nil, fmt.Errorf("private src host not allowed") | |||
| 137 | - | } | |||
| 138 | - | ips = append(ips, addr.IP) | |||
| 139 | - | } | |||
| 140 | - | return ips, nil | |||
| 141 | - | } | |||
| 142 | - | ||||
| 143 | - | func isBlockedTranscodeIP(ip net.IP) bool { | |||
| 144 | - | return netguard.IsBlockedTranscodeIP(ip) | |||
| 145 | - | } | |||
| 146 | - | ||||
| 147 | - | func isStreamingManifestResponse(resp *http.Response) bool { | |||
| 148 | - | if resp == nil { | |||
| 149 | - | return false | |||
| 150 | - | } | |||
| 151 | - | if resp.Request != nil && resp.Request.URL != nil && IsStreamingManifestURL(resp.Request.URL.String()) { | |||
| 152 | - | return true | |||
| 153 | - | } | |||
| 154 | - | contentType := strings.ToLower(resp.Header.Get("Content-Type")) | |||
| 155 | - | return strings.Contains(contentType, "mpegurl") || strings.Contains(contentType, "dash+xml") | |||
| 156 | - | } | |||
| 157 | - | ||||
| 158 | - | func newPipeline( | |||
| 159 | - | sessionID string, | |||
| 160 | - | hwCaps port.HWCapabilities, | |||
| 161 | - | sourceURL string, | |||
| 162 | - | headers map[string]string, | |||
| 163 | - | quality string, | |||
| 164 | - | pw *io.PipeWriter, | |||
| 165 | - | logger zerolog.Logger, | |||
| 166 | - | ) *pipeline { | |||
| 167 | - | return &pipeline{ | |||
| 168 | - | sessionID: sessionID, | |||
| 169 | - | hwCaps: hwCaps, | |||
| 170 | - | sourceURL: sourceURL, | |||
| 171 | - | headers: headers, | |||
| 172 | - | quality: quality, | |||
| 173 | - | pw: pw, | |||
| 174 | - | logger: logger, | |||
| 175 | - | } | |||
| 176 | - | } | |||
| 177 | - | ||||
| 178 | - | // run executes the full transcode pipeline in the calling goroutine. | |||
| 179 | - | // It closes pw when done (with error or nil). | |||
| 180 | - | func (p *pipeline) run(ctx context.Context) { | |||
| 181 | - | err := p.doRun(ctx) | |||
| 182 | - | if err != nil { | |||
| 183 | - | log := p.logger.With(). | |||
| 184 | - | Str("session_id", p.sessionID). | |||
| 185 | - | Str("source_url", p.sourceURL). | |||
| 186 | - | Logger() | |||
| 187 | - | if errors.Is(err, context.Canceled) { | |||
| 188 | - | log.Debug().Err(err).Msg("transcode pipeline canceled") | |||
| 189 | - | } else { | |||
| 190 | - | log.Error().Err(err).Msg("transcode pipeline failed") | |||
| 191 | - | } | |||
| 192 | - | _ = p.pw.CloseWithError(err) | |||
| 193 | - | } else { | |||
| 194 | - | p.logger.Debug(). | |||
| 195 | - | Str("session_id", p.sessionID). | |||
| 196 | - | Str("source_url", p.sourceURL). | |||
| 197 | - | Msg("transcode pipeline completed") | |||
| 198 | - | _ = p.pw.Close() | |||
| 199 | - | } | |||
| 200 | - | } | |||
| 201 | - | ||||
| 202 | - | // doRun contains the actual pipeline logic. Errors are returned, and | |||
| 203 | - | // the caller (run) closes the pipe writer accordingly. | |||
| 204 | - | func (p *pipeline) doRun(ctx context.Context) error { | |||
| 205 | - | return p.doRunImpl(ctx) | |||
| 206 | - | } | |||
| 207 | - | ||||
| 208 | - | //nolint:gocyclo,funlen,gosec // FFmpeg pipeline setup/teardown uses necessary branching and unsafe C-API calls. | |||
| 209 | - | func (p *pipeline) doRunImpl(ctx context.Context) error { | |||
| 210 | - | // --- Step 1-3: Open the input using the appropriate strategy --- | |||
| 211 | - | inFmtCtx, closeInput, err := p.openInputFormatContext(ctx) | |||
| 212 | - | if err != nil { | |||
| 213 | - | return err | |||
| 214 | - | } | |||
| 215 | - | defer closeInput() | |||
| 216 | - | ||||
| 217 | - | // --- Step 4: Find stream info --- | |||
| 218 | - | if ret := ffmpeg.FormatFindStreamInfo(inFmtCtx, nil); ret < 0 { | |||
| 219 | - | return fmt.Errorf("find stream info: %d", ret) | |||
| 220 | - | } | |||
| 221 | - | ||||
| 222 | - | // --- Step 5: Select input video/audio streams --- | |||
| 223 | - | videoIdx, audioIdx, err := p.selectInputStreams(inFmtCtx) | |||
| 224 | - | if err != nil { | |||
| 225 | - | return err | |||
| 226 | - | } | |||
| 227 | - | ||||
| 228 | - | videoStream := ffmpeg.FmtCtxStream(inFmtCtx, videoIdx) | |||
| 229 | - | videoStreamWrap := ffmpeg.WrapStream(videoStream) | |||
| 230 | - | videoCodecPar := videoStreamWrap.Codecpar() | |||
| 231 | - | ||||
| 232 | - | // --- Step 6: Open video decoder --- | |||
| 233 | - | vidDecCtx, err := p.openVideoDecoder(videoCodecPar) | |||
| 234 | - | if err != nil { | |||
| 235 | - | return fmt.Errorf("open video decoder: %w", err) | |||
| 236 | - | } | |||
| 237 | - | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts. | |||
| 238 | - | defer ffmpeg.CodecFreeContext(unsafe.Pointer(&vidDecCtx)) | |||
| 239 | - | ||||
| 240 | - | // --- Step 7-8: Open video encoder with HW accel --- | |||
| 241 | - | hwDeviceCtx, vidEncCtx, err := p.openVideoEncoder(vidDecCtx) | |||
| 242 | - | if err != nil { | |||
| 243 | - | return fmt.Errorf("open video encoder: %w", err) | |||
| 244 | - | } | |||
| 245 | - | if hwDeviceCtx != nil { | |||
| 246 | - | defer ffmpeg.BufferUnref(&hwDeviceCtx) | |||
| 247 | - | } | |||
| 248 | - | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts. | |||
| 249 | - | defer ffmpeg.CodecFreeContext(unsafe.Pointer(&vidEncCtx)) | |||
| 250 | - | ||||
| 251 | - | // --- Step 9: Create output format context for WebM --- | |||
| 252 | - | var outFmtCtx unsafe.Pointer | |||
| 253 | - | //nolint:gosec // FFmpeg requires pointer-to-pointer allocation for output contexts. | |||
| 254 | - | if ret := ffmpeg.FormatAllocOutputContext2(unsafe.Pointer(&outFmtCtx), nil, "webm", ""); ret < 0 { | |||
| 255 | - | return fmt.Errorf("alloc output context: %d", ret) | |||
| 256 | - | } | |||
| 257 | - | defer ffmpeg.FormatFreeContext(outFmtCtx) | |||
| 258 | - | ||||
| 259 | - | // --- Step 10: Create write AVIO callback --- | |||
| 260 | - | pw := p.pw | |||
| 261 | - | writeFn := func(_ unsafe.Pointer, buf unsafe.Pointer, bufSize int32) int32 { | |||
| 262 | - | if bufSize <= 0 { | |||
| 263 | - | return 0 | |||
| 264 | - | } | |||
| 265 | - | //nolint:gosec // FFmpeg supplies a valid callback buffer and explicit byte count here. | |||
| 266 | - | goBuf := unsafe.Slice((*byte)(buf), int(bufSize)) | |||
| 267 | - | n, writeErr := pw.Write(goBuf) | |||
| 268 | - | if writeErr != nil { | |||
| 269 | - | return -1 | |||
| 270 | - | } | |||
| 271 | - | return int32(n) | |||
| 272 | - | } | |||
| 273 | - | p.writeCb = purego.NewCallback(writeFn) | |||
| 274 | - | ||||
| 275 | - | writeAVIO := ffmpeg.AvioAllocContext(avioBufSize, true, nil, 0, p.writeCb, 0) | |||
| 276 | - | if writeAVIO == nil { | |||
| 277 | - | return errors.New("failed to allocate write AVIO context") | |||
| 278 | - | } | |||
| 279 | - | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for AVIO contexts. | |||
| 280 | - | defer ffmpeg.AvioContextFree(unsafe.Pointer(&writeAVIO)) | |||
| 281 | - | ||||
| 282 | - | ffmpeg.FmtCtxSetPB(outFmtCtx, writeAVIO) | |||
| 283 | - | ffmpeg.FmtCtxSetFlags(outFmtCtx, ffmpeg.FmtCtxFlags(outFmtCtx)|ffmpeg.AVFMT_FLAG_CUSTOM_IO) | |||
| 284 | - | ||||
| 285 | - | // --- Step 11: Add video output stream --- | |||
| 286 | - | outVideoStream := ffmpeg.FormatNewStream(outFmtCtx, nil) | |||
| 287 | - | if outVideoStream == nil { | |||
| 288 | - | return errors.New("failed to create output video stream") | |||
| 289 | - | } | |||
| 290 | - | outVideoStreamWrap := ffmpeg.WrapStream(outVideoStream) | |||
| 291 | - | outVideoIdx := int(outVideoStreamWrap.Index()) | |||
| 292 | - | ||||
| 293 | - | if ret := ffmpeg.CodecParametersFromContext(outVideoStreamWrap.Codecpar(), vidEncCtx); ret < 0 { | |||
| 294 | - | return fmt.Errorf("copy video encoder params to stream: %d", ret) | |||
| 295 | - | } | |||
| 296 | - | ||||
| 297 | - | // --- Step 11b: Add audio output stream (if audio present) --- | |||
| 298 | - | var audioTx *audioTranscoder | |||
| 299 | - | if audioIdx >= 0 { | |||
| 300 | - | audioStream := ffmpeg.FmtCtxStream(inFmtCtx, audioIdx) | |||
| 301 | - | audioStreamWrap := ffmpeg.WrapStream(audioStream) | |||
| 302 | - | audioCodecPar := audioStreamWrap.Codecpar() | |||
| 303 | - | ||||
| 304 | - | var txErr error | |||
| 305 | - | audioTx, txErr = newAudioTranscoder(audioCodecPar, outFmtCtx, audioIdx) | |||
| 306 | - | if txErr != nil { | |||
| 307 | - | // Audio transcoding failure is non-fatal; we proceed with video only. | |||
| 308 | - | audioTx = nil | |||
| 309 | - | audioIdx = -1 | |||
| 310 | - | } | |||
| 311 | - | } | |||
| 312 | - | // Use a closure so the defer sees the current value of audioTx at | |||
| 313 | - | // cleanup time. This allows us to close and nil-out audioTx mid-loop | |||
| 314 | - | // (e.g., on audio processing error) without double-closing. | |||
| 315 | - | defer func() { | |||
| 316 | - | if audioTx != nil { | |||
| 317 | - | audioTx.close() | |||
| 318 | - | } | |||
| 319 | - | }() | |||
| 320 | - | ||||
| 321 | - | // --- Step 12: Write header --- | |||
| 322 | - | if ret := ffmpeg.FormatWriteHeader(outFmtCtx, nil); ret < 0 { | |||
| 323 | - | return fmt.Errorf("write header: %d", ret) | |||
| 324 | - | } | |||
| 325 | - | ||||
| 326 | - | // --- Step 13: Main decode/encode loop --- | |||
| 327 | - | pkt := ffmpeg.PacketAlloc() | |||
| 328 | - | if pkt == nil { | |||
| 329 | - | return errors.New("failed to allocate read packet") | |||
| 330 | - | } | |||
| 331 | - | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for packet allocations. | |||
| 332 | - | defer ffmpeg.PacketFree(unsafe.Pointer(&pkt)) | |||
| 333 | - | ||||
| 334 | - | frame := ffmpeg.FrameAlloc() | |||
| 335 | - | if frame == nil { | |||
| 336 | - | return errors.New("failed to allocate decode frame") | |||
| 337 | - | } | |||
| 338 | - | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for frame allocations. | |||
| 339 | - | defer ffmpeg.FrameFree(unsafe.Pointer(&frame)) | |||
| 340 | - | ||||
| 341 | - | encPkt := ffmpeg.PacketAlloc() | |||
| 342 | - | if encPkt == nil { | |||
| 343 | - | return errors.New("failed to allocate encode packet") | |||
| 344 | - | } | |||
| 345 | - | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for packet allocations. | |||
| 346 | - | defer ffmpeg.PacketFree(unsafe.Pointer(&encPkt)) | |||
| 347 | - | ||||
| 348 | - | for { | |||
| 349 | - | // Check context cancellation. | |||
| 350 | - | select { | |||
| 351 | - | case <-ctx.Done(): | |||
| 352 | - | return ctx.Err() | |||
| 353 | - | default: | |||
| 354 | - | } | |||
| 355 | - | ||||
| 356 | - | ret := ffmpeg.ReadFrame(inFmtCtx, pkt) | |||
| 357 | - | if ret < 0 { | |||
| 358 | - | // EOF or error — break to flush. | |||
| 359 | - | break | |||
| 360 | - | } | |||
| 361 | - | ||||
| 362 | - | streamIdx := int(pktStreamIndex(pkt)) | |||
| 363 | - | ||||
| 364 | - | switch { | |||
| 365 | - | case streamIdx == videoIdx: | |||
| 366 | - | if err := p.transcodeVideoPacket(pkt, frame, encPkt, vidDecCtx, vidEncCtx, outFmtCtx, outVideoIdx); err != nil { | |||
| 367 | - | ffmpeg.PacketUnref(pkt) | |||
| 368 | - | return fmt.Errorf("video transcode: %w", err) | |||
| 369 | - | } | |||
| 370 | - | ||||
| 371 | - | case audioTx != nil && streamIdx == audioIdx: | |||
| 372 | - | if err := audioTx.processPacket(pkt, outFmtCtx); err != nil { | |||
| 373 | - | // Audio errors are non-fatal: log warning, disable audio, | |||
| 374 | - | // and continue with video-only output. This matches the | |||
| 375 | - | // non-fatal handling of audio init failure above. | |||
| 376 | - | p.logger.Warn(). | |||
| 377 | - | Str("session_id", p.sessionID). | |||
| 378 | - | Err(err). | |||
| 379 | - | Msg("audio processing failed, continuing with video only") | |||
| 380 | - | audioTx.close() | |||
| 381 | - | audioTx = nil | |||
| 382 | - | audioIdx = -1 | |||
| 383 | - | } | |||
| 384 | - | } | |||
| 385 | - | ||||
| 386 | - | ffmpeg.PacketUnref(pkt) | |||
| 387 | - | } | |||
| 388 | - | ||||
| 389 | - | // --- Step 14: Flush decoders and encoders --- | |||
| 390 | - | // Flush video decoder. | |||
| 391 | - | ffmpeg.CodecSendPacket(vidDecCtx, nil) | |||
| 392 | - | for { | |||
| 393 | - | ret := ffmpeg.CodecReceiveFrame(vidDecCtx, frame) | |||
| 394 | - | if ret < 0 { | |||
| 395 | - | break | |||
| 396 | - | } | |||
| 397 | - | if err := p.encodeAndWriteVideoFrame(frame, encPkt, vidEncCtx, outFmtCtx, outVideoIdx); err != nil { | |||
| 398 | - | ffmpeg.FrameUnref(frame) | |||
| 399 | - | return fmt.Errorf("video flush: %w", err) | |||
| 400 | - | } | |||
| 401 | - | ffmpeg.FrameUnref(frame) | |||
| 402 | - | } | |||
| 403 | - | ||||
| 404 | - | // Flush video encoder. | |||
| 405 | - | ffmpeg.CodecSendFrame(vidEncCtx, nil) | |||
| 406 | - | for { | |||
| 407 | - | ret := ffmpeg.CodecReceivePacket(vidEncCtx, encPkt) | |||
| 408 | - | if ret < 0 { | |||
| 409 | - | break | |||
| 410 | - | } | |||
| 411 | - | pktSetStreamIndex(encPkt, int32(outVideoIdx)) | |||
| 412 | - | outVidStream := ffmpeg.FmtCtxStream(outFmtCtx, outVideoIdx) | |||
| 413 | - | outVidStreamWrap := ffmpeg.WrapStream(outVidStream) | |||
| 414 | - | ffmpeg.PacketRescaleTs(encPkt, ffmpeg.CodecCtxTimeBase(vidEncCtx), outVidStreamWrap.TimeBase()) | |||
| 415 | - | ffmpeg.InterleavedWriteFrame(outFmtCtx, encPkt) | |||
| 416 | - | ffmpeg.PacketUnref(encPkt) | |||
| 417 | - | } | |||
| 418 | - | ||||
| 419 | - | // Flush audio. | |||
| 420 | - | if audioTx != nil { | |||
| 421 | - | audioTx.flush(outFmtCtx, p.logger) | |||
| 422 | - | } | |||
| 423 | - | ||||
| 424 | - | // --- Step 15: Write trailer --- | |||
| 425 | - | if ret := ffmpeg.WriteTrailer(outFmtCtx); ret < 0 { | |||
| 426 | - | return fmt.Errorf("write trailer: %d", ret) | |||
| 427 | - | } | |||
| 428 | - | ||||
| 429 | - | return nil | |||
| 430 | - | } | |||
| 431 | - | ||||
| 432 | - | func (p *pipeline) selectInputStreams(inFmtCtx unsafe.Pointer) (videoIdx, audioIdx int, err error) { | |||
| 433 | - | videoIdx = -1 | |||
| 434 | - | audioIdx = -1 | |||
| 435 | - | bestVideoPixels := int64(-1) | |||
| 436 | - | ||||
| 437 | - | streamCount := int(ffmpeg.FmtCtxNbStreams(inFmtCtx)) | |||
| 438 | - | for i := 0; i < streamCount; i++ { | |||
| 439 | - | stream := ffmpeg.FmtCtxStream(inFmtCtx, i) | |||
| 440 | - | if stream == nil { | |||
| 441 | - | continue | |||
| 442 | - | } | |||
| 443 | - | streamWrap := ffmpeg.WrapStream(stream) | |||
| 444 | - | codecPar := streamWrap.Codecpar() | |||
| 445 | - | if codecPar == nil { | |||
| 446 | - | continue | |||
| 447 | - | } | |||
| 448 | - | ||||
| 449 | - | switch ffmpeg.CodecParCodecType(codecPar) { | |||
| 450 | - | case ffmpeg.AVMEDIA_TYPE_VIDEO: | |||
| 451 | - | width := int64(ffmpeg.CodecParWidth(codecPar)) | |||
| 452 | - | height := int64(ffmpeg.CodecParHeight(codecPar)) | |||
| 453 | - | pixels := width * height | |||
| 454 | - | if videoIdx == -1 || pixels > bestVideoPixels { | |||
| 455 | - | videoIdx = i | |||
| 456 | - | bestVideoPixels = pixels | |||
| 457 | - | } | |||
| 458 | - | case ffmpeg.AVMEDIA_TYPE_AUDIO: | |||
| 459 | - | if audioIdx == -1 { | |||
| 460 | - | audioIdx = i | |||
| 461 | - | } | |||
| 462 | - | } | |||
| 463 | - | } | |||
| 464 | - | ||||
| 465 | - | if videoIdx < 0 { | |||
| 466 | - | return -1, audioIdx, errors.New("no video stream found in source") | |||
| 467 | - | } | |||
| 468 | - | ||||
| 469 | - | videoCodecPar := ffmpeg.WrapStream(ffmpeg.FmtCtxStream(inFmtCtx, videoIdx)).Codecpar() | |||
| 470 | - | log := p.logger.With(). | |||
| 471 | - | Str("session_id", p.sessionID). | |||
| 472 | - | Str("source_url", p.sourceURL). | |||
| 473 | - | Int("video_stream_index", videoIdx). | |||
| 474 | - | Int32("video_codec_id", ffmpeg.CodecParCodecID(videoCodecPar)). | |||
| 475 | - | Int32("video_width", ffmpeg.CodecParWidth(videoCodecPar)). | |||
| 476 | - | Int32("video_height", ffmpeg.CodecParHeight(videoCodecPar)). | |||
| 477 | - | Logger() | |||
| 478 | - | if audioIdx >= 0 { | |||
| 479 | - | audioCodecPar := ffmpeg.WrapStream(ffmpeg.FmtCtxStream(inFmtCtx, audioIdx)).Codecpar() | |||
| 480 | - | log.Info(). | |||
| 481 | - | Int("audio_stream_index", audioIdx). | |||
| 482 | - | Int32("audio_codec_id", ffmpeg.CodecParCodecID(audioCodecPar)). | |||
| 483 | - | Msg("transcode pipeline selected input streams") | |||
| 484 | - | } else { | |||
| 485 | - | log.Info().Msg("transcode pipeline selected input streams") | |||
| 486 | - | } | |||
| 487 | - | ||||
| 488 | - | return videoIdx, audioIdx, nil | |||
| 489 | - | } | |||
| 490 | - | ||||
| 491 | - | func (p *pipeline) openInputFormatContext(ctx context.Context) (unsafe.Pointer, func(), error) { | |||
| 492 | - | if IsStreamingManifestURL(p.sourceURL) { | |||
| 493 | - | // FFmpeg resolves manifest redirects and segment URLs internally, bypassing | |||
| 494 | - | // the guarded Go HTTP client below. Keep untrusted transcode requests on | |||
| 495 | - | // custom I/O until manifests can be fetched and rewritten through the same | |||
| 496 | - | // SSRF protections. | |||
| 497 | - | return nil, nil, errors.New("streaming manifest transcoding is disabled") | |||
| 498 | - | } | |||
| 499 | - | return p.openCustomIOInputContext(ctx) | |||
| 500 | - | } | |||
| 501 | - | ||||
| 502 | - | func (p *pipeline) openCustomIOInputContext(ctx context.Context) (unsafe.Pointer, func(), error) { | |||
| 503 | - | req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.sourceURL, http.NoBody) | |||
| 504 | - | if err != nil { | |||
| 505 | - | return nil, nil, fmt.Errorf("create request: %w", err) | |||
| 506 | - | } | |||
| 507 | - | for k, v := range p.headers { | |||
| 508 | - | req.Header.Set(k, v) | |||
| 509 | - | } | |||
| 510 | - | ||||
| 511 | - | resp, err := httpClient.Do(req) //nolint:bodyclose // closed by returned cleanup after FFmpeg finishes reading. | |||
| 512 | - | if err != nil { | |||
| 513 | - | return nil, nil, fmt.Errorf("fetch source: %w", err) | |||
| 514 | - | } | |||
| 515 | - | ||||
| 516 | - | if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent { | |||
| 517 | - | _ = resp.Body.Close() | |||
| 518 | - | return nil, nil, fmt.Errorf("source returned HTTP %d", resp.StatusCode) | |||
| 519 | - | } | |||
| 520 | - | if isStreamingManifestResponse(resp) { | |||
| 521 | - | _ = resp.Body.Close() | |||
| 522 | - | return nil, nil, errors.New("streaming manifest transcoding is disabled") | |||
| 523 | - | } | |||
| 524 | - | ||||
| 525 | - | body := resp.Body | |||
| 526 | - | readFn := func(_ unsafe.Pointer, buf unsafe.Pointer, bufSize int32) int32 { | |||
| 527 | - | if bufSize <= 0 { | |||
| 528 | - | return -1 | |||
| 529 | - | } | |||
| 530 | - | //nolint:gosec // FFmpeg supplies a valid callback buffer and explicit byte count here. | |||
| 531 | - | goBuf := unsafe.Slice((*byte)(buf), int(bufSize)) | |||
| 532 | - | // Retry up to 3 times for the edge case where body.Read returns | |||
| 533 | - | // n=0, err=nil (allowed by the io.Reader contract but unusual). | |||
| 534 | - | for tries := 0; tries < 3; tries++ { | |||
| 535 | - | n, readErr := body.Read(goBuf) | |||
| 536 | - | if n > 0 { | |||
| 537 | - | return int32(n) | |||
| 538 | - | } | |||
| 539 | - | if readErr != nil { | |||
| 540 | - | // Return AVERROR_EOF for EOF, generic error otherwise. | |||
| 541 | - | return averrorEOF | |||
| 542 | - | } | |||
| 543 | - | // n == 0 && err == nil: retry | |||
| 544 | - | } | |||
| 545 | - | return averrorEOF | |||
| 546 | - | } | |||
| 547 | - | p.readCb = purego.NewCallback(readFn) | |||
| 548 | - | ||||
| 549 | - | readAVIO := ffmpeg.AvioAllocContext(avioBufSize, false, nil, p.readCb, 0, 0) | |||
| 550 | - | if readAVIO == nil { | |||
| 551 | - | _ = resp.Body.Close() | |||
| 552 | - | return nil, nil, errors.New("failed to allocate read AVIO context") | |||
| 553 | - | } | |||
| 554 | - | ||||
| 555 | - | inFmtCtx := ffmpeg.FormatAllocContext() | |||
| 556 | - | if inFmtCtx == nil { | |||
| 557 | - | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for AVIO contexts. | |||
| 558 | - | ffmpeg.AvioContextFree(unsafe.Pointer(&readAVIO)) | |||
| 559 | - | _ = resp.Body.Close() | |||
| 560 | - | return nil, nil, errors.New("failed to allocate input format context") | |||
| 561 | - | } | |||
| 562 | - | ||||
| 563 | - | ffmpeg.FmtCtxSetPB(inFmtCtx, readAVIO) | |||
| 564 | - | ffmpeg.FmtCtxSetFlags(inFmtCtx, ffmpeg.FmtCtxFlags(inFmtCtx)|ffmpeg.AVFMT_FLAG_CUSTOM_IO) | |||
| 565 | - | ||||
| 566 | - | //nolint:gosec // FFmpeg requires pointer-to-pointer input opening for format contexts. | |||
| 567 | - | if ret := ffmpeg.FormatOpenInput(unsafe.Pointer(&inFmtCtx), p.sourceURL, nil, nil); ret < 0 { | |||
| 568 | - | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for AVIO contexts. | |||
| 569 | - | ffmpeg.AvioContextFree(unsafe.Pointer(&readAVIO)) | |||
| 570 | - | _ = resp.Body.Close() | |||
| 571 | - | ffmpeg.FormatFreeContext(inFmtCtx) | |||
| 572 | - | return nil, nil, fmt.Errorf("open input: %d", ret) | |||
| 573 | - | } | |||
| 574 | - | ||||
| 575 | - | cleanup := func() { | |||
| 576 | - | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for format contexts. | |||
| 577 | - | ffmpeg.FormatCloseInput(unsafe.Pointer(&inFmtCtx)) | |||
| 578 | - | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for AVIO contexts. | |||
| 579 | - | ffmpeg.AvioContextFree(unsafe.Pointer(&readAVIO)) | |||
| 580 | - | _ = resp.Body.Close() | |||
| 581 | - | } | |||
| 582 | - | return inFmtCtx, cleanup, nil | |||
| 583 | - | } | |||
| 584 | - | ||||
| 585 | - | func (p *pipeline) openVideoDecoder(codecPar unsafe.Pointer) (unsafe.Pointer, error) { | |||
| 586 | - | codecID := ffmpeg.CodecParCodecID(codecPar) | |||
| 587 | - | ||||
| 588 | - | // Try HW decoder first. | |||
| 589 | - | var dec unsafe.Pointer | |||
| 590 | - | for _, name := range p.hwCaps.Decoders { | |||
| 591 | - | dec = ffmpeg.CodecFindDecoderByName(name) | |||
| 592 | - | if dec != nil { | |||
| 593 | - | break | |||
| 594 | - | } | |||
| 595 | - | } | |||
| 596 | - | ||||
| 597 | - | // Fallback to software decoder. | |||
| 598 | - | if dec == nil { | |||
| 599 | - | dec = ffmpeg.CodecFindDecoder(codecID) | |||
| 600 | - | } | |||
| 601 | - | if dec == nil { | |||
| 602 | - | return nil, fmt.Errorf("no decoder found for codec ID %d", codecID) | |||
| 603 | - | } | |||
| 604 | - | ||||
| 605 | - | decCtx := ffmpeg.CodecAllocContext3(dec) | |||
| 606 | - | if decCtx == nil { | |||
| 607 | - | return nil, errors.New("failed to allocate video decoder context") | |||
| 608 | - | } | |||
| 609 | - | ||||
| 610 | - | if ret := ffmpeg.CodecParametersToContext(decCtx, codecPar); ret < 0 { | |||
| 611 | - | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts. | |||
| 612 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx)) | |||
| 613 | - | return nil, fmt.Errorf("copy video codec params: %d", ret) | |||
| 614 | - | } | |||
| 615 | - | ||||
| 616 | - | if ret := ffmpeg.CodecOpen2(decCtx, dec, nil); ret < 0 { | |||
| 617 | - | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts. | |||
| 618 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx)) | |||
| 619 | - | return nil, fmt.Errorf("open video decoder: %d", ret) | |||
| 620 | - | } | |||
| 621 | - | ||||
| 622 | - | return decCtx, nil | |||
| 623 | - | } | |||
| 624 | - | ||||
| 625 | - | // openVideoEncoder creates a hardware device context and encoder for | |||
| 626 | - | // the video stream. It uses the first available encoder from hwCaps.Encoders. | |||
| 627 | - | func (p *pipeline) openVideoEncoder(decCtx unsafe.Pointer) (hwDeviceCtx, encCtx unsafe.Pointer, err error) { | |||
| 628 | - | if len(p.hwCaps.Encoders) == 0 { | |||
| 629 | - | return nil, nil, errors.New("no GPU encoders available") | |||
| 630 | - | } | |||
| 631 | - | ||||
| 632 | - | // Create the HW device context. | |||
| 633 | - | var deviceType int32 | |||
| 634 | - | switch p.hwCaps.API { | |||
| 635 | - | case hwAPINameVAAPI: | |||
| 636 | - | deviceType = int32(ffmpeg.HwdeviceTypeVaapi) | |||
| 637 | - | case "nvenc": | |||
| 638 | - | deviceType = int32(ffmpeg.HwdeviceTypeCuda) | |||
| 639 | - | default: | |||
| 640 | - | return nil, nil, fmt.Errorf("unknown HW API: %s", p.hwCaps.API) | |||
| 641 | - | } | |||
| 642 | - | ||||
| 643 | - | hwDeviceCtx, hwErr := ffmpeg.HWDeviceCtxCreate(deviceType, p.hwCaps.Device) | |||
| 644 | - | if hwErr != nil { | |||
| 645 | - | return nil, nil, fmt.Errorf("create HW device: %w", hwErr) | |||
| 646 | - | } | |||
| 647 | - | ||||
| 648 | - | width := ffmpeg.CodecCtxWidth(decCtx) | |||
| 649 | - | height := ffmpeg.CodecCtxHeight(decCtx) | |||
| 650 | - | ||||
| 651 | - | var lastErr error | |||
| 652 | - | for _, name := range p.hwCaps.Encoders { | |||
| 653 | - | enc := ffmpeg.CodecFindEncoderByName(name) | |||
| 654 | - | if enc == nil { | |||
| 655 | - | continue | |||
| 656 | - | } | |||
| 657 | - | ||||
| 658 | - | encCtx, err = p.tryOpenVideoEncoder(decCtx, hwDeviceCtx, name, enc) | |||
| 659 | - | if err == nil { | |||
| 660 | - | p.logger.Info(). | |||
| 661 | - | Str("session_id", p.sessionID). | |||
| 662 | - | Str("source_url", p.sourceURL). | |||
| 663 | - | Str("encoder", name). | |||
| 664 | - | Str("api", p.hwCaps.API). | |||
| 665 | - | Int32("width", width). | |||
| 666 | - | Int32("height", height). | |||
| 667 | - | Msg("transcode pipeline selected video encoder") | |||
| 668 | - | return hwDeviceCtx, encCtx, nil | |||
| 669 | - | } | |||
| 670 | - | lastErr = err | |||
| 671 | - | p.logger.Warn(). | |||
| 672 | - | Str("session_id", p.sessionID). | |||
| 673 | - | Str("source_url", p.sourceURL). | |||
| 674 | - | Str("encoder", name). | |||
| 675 | - | Err(err). | |||
| 676 | - | Msg("transcode pipeline encoder candidate failed") | |||
| 677 | - | } | |||
| 678 | - | ||||
| 679 | - | ffmpeg.BufferUnref(&hwDeviceCtx) | |||
| 680 | - | if lastErr != nil { | |||
| 681 | - | return nil, nil, fmt.Errorf("no usable encoder codec found: %w", lastErr) | |||
| 682 | - | } | |||
| 683 | - | return nil, nil, errors.New("no usable encoder codec found") | |||
| 684 | - | } | |||
| 685 | - | ||||
| 686 | - | func (p *pipeline) tryOpenVideoEncoder(decCtx, hwDeviceCtx unsafe.Pointer, encoderName string, enc unsafe.Pointer) (unsafe.Pointer, error) { | |||
| 687 | - | encCtx := ffmpeg.CodecAllocContext3(enc) | |||
| 688 | - | if encCtx == nil { | |||
| 689 | - | return nil, errors.New("failed to allocate video encoder context") | |||
| 690 | - | } | |||
| 691 | - | ||||
| 692 | - | width := ffmpeg.CodecCtxWidth(decCtx) | |||
| 693 | - | height := ffmpeg.CodecCtxHeight(decCtx) | |||
| 694 | - | ffmpeg.CodecCtxSetWidth(encCtx, width) | |||
| 695 | - | ffmpeg.CodecCtxSetHeight(encCtx, height) | |||
| 696 | - | ||||
| 697 | - | framerate := ffmpeg.CodecCtxFramerate(decCtx) | |||
| 698 | - | if framerate.Num == 0 { | |||
| 699 | - | framerate = ffmpeg.AVRational{Num: 30, Den: 1} | |||
| 700 | - | } | |||
| 701 | - | ffmpeg.CodecCtxSetFramerate(encCtx, framerate) | |||
| 702 | - | ffmpeg.CodecCtxSetTimeBase(encCtx, ffmpeg.AVRational{Num: framerate.Den, Den: framerate.Num}) | |||
| 703 | - | ||||
| 704 | - | if p.hwCaps.API == hwAPINameVAAPI { | |||
| 705 | - | ffmpeg.CodecCtxSetPixFmt(encCtx, int32(ffmpeg.PixFmtVaapi)) | |||
| 706 | - | } else { | |||
| 707 | - | ffmpeg.CodecCtxSetPixFmt(encCtx, int32(ffmpeg.PixFmtNv12)) | |||
| 708 | - | } | |||
| 709 | - | ||||
| 710 | - | gopSize := framerate.Num / framerate.Den * 2 | |||
| 711 | - | if gopSize <= 0 { | |||
| 712 | - | gopSize = 60 | |||
| 713 | - | } | |||
| 714 | - | codecCtxSetGopSize(encCtx, gopSize) | |||
| 715 | - | p.applyQualityPreset(encCtx, encoderName, width, height) | |||
| 716 | - | ||||
| 717 | - | hwDeviceRef := ffmpeg.BufferRef(hwDeviceCtx) | |||
| 718 | - | if hwDeviceRef == nil { | |||
| 719 | - | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts. | |||
| 720 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx)) | |||
| 721 | - | return nil, errors.New("failed to ref HW device context for encoder") | |||
| 722 | - | } | |||
| 723 | - | ffmpeg.CodecCtxSetHwDeviceCtx(encCtx, hwDeviceRef) | |||
| 724 | - | ||||
| 725 | - | if p.hwCaps.API == hwAPINameVAAPI { | |||
| 726 | - | framesRef := ffmpeg.HWFrameCtxAlloc(hwDeviceCtx) | |||
| 727 | - | if framesRef == nil { | |||
| 728 | - | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts. | |||
| 729 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx)) | |||
| 730 | - | return nil, errors.New("failed to allocate HW frames context") | |||
| 731 | - | } | |||
| 732 | - | ||||
| 733 | - | hwFramesData := ffmpeg.BufferRefData(framesRef) | |||
| 734 | - | if hwFramesData == nil { | |||
| 735 | - | ffmpeg.BufferUnref(&framesRef) | |||
| 736 | - | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts. | |||
| 737 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx)) | |||
| 738 | - | return nil, errors.New("failed to access HW frames context payload") | |||
| 739 | - | } | |||
| 740 | - | ||||
| 741 | - | ffmpeg.HWFramesCtxSetInitialPoolSize(hwFramesData, vaapiInitialPoolSize) | |||
| 742 | - | ffmpeg.HWFramesCtxSetFormat(hwFramesData, int32(ffmpeg.PixFmtVaapi)) | |||
| 743 | - | ffmpeg.HWFramesCtxSetSWFormat(hwFramesData, int32(ffmpeg.PixFmtNv12)) | |||
| 744 | - | ffmpeg.HWFramesCtxSetWidth(hwFramesData, width) | |||
| 745 | - | ffmpeg.HWFramesCtxSetHeight(hwFramesData, height) | |||
| 746 | - | ||||
| 747 | - | if ret := ffmpeg.HWFrameCtxInit(framesRef); ret < 0 { | |||
| 748 | - | ffmpeg.BufferUnref(&framesRef) | |||
| 749 | - | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts. | |||
| 750 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx)) | |||
| 751 | - | return nil, fmt.Errorf("init HW frames context: %d", ret) | |||
| 752 | - | } | |||
| 753 | - | ||||
| 754 | - | ffmpeg.CodecCtxSetHwFramesCtx(encCtx, framesRef) | |||
| 755 | - | } | |||
| 756 | - | ||||
| 757 | - | if ret := ffmpeg.CodecOpen2(encCtx, enc, nil); ret < 0 { | |||
| 758 | - | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts. | |||
| 759 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx)) | |||
| 760 | - | return nil, fmt.Errorf("open video encoder %s: %d", encoderName, ret) | |||
| 761 | - | } | |||
| 762 | - | ||||
| 763 | - | return encCtx, nil | |||
| 764 | - | } | |||
| 765 | - | ||||
| 766 | - | // applyQualityPreset sets bitrate and encoder options based on the | |||
| 767 | - | // quality string ("low", "medium", "high"). | |||
| 768 | - | func (p *pipeline) applyQualityPreset(encCtx unsafe.Pointer, encoderName string, width, height int32) { | |||
| 769 | - | pixels := int64(width) * int64(height) | |||
| 770 | - | ||||
| 771 | - | // Base bitrate scaled to resolution (targeting 1080p baseline). | |||
| 772 | - | // low: ~1.5 Mbps at 1080p, medium: ~3 Mbps, high: ~6 Mbps. | |||
| 773 | - | var bitsPerPixel float64 | |||
| 774 | - | switch p.quality { | |||
| 775 | - | case "low": | |||
| 776 | - | bitsPerPixel = 0.07 | |||
| 777 | - | case "high": | |||
| 778 | - | bitsPerPixel = 0.30 | |||
| 779 | - | default: // "medium" | |||
| 780 | - | bitsPerPixel = 0.15 | |||
| 781 | - | } | |||
| 782 | - | ||||
| 783 | - | bitrate := int64(float64(pixels) * bitsPerPixel) | |||
| 784 | - | if bitrate < minVideoBitrate { | |||
| 785 | - | bitrate = minVideoBitrate // floor at 500 kbps | |||
| 786 | - | } | |||
| 787 | - | codecCtxSetBitRate(encCtx, bitrate) | |||
| 788 | - | ||||
| 789 | - | // Set encoder-specific options via av_opt_set. | |||
| 790 | - | if strings.HasSuffix(encoderName, "_vaapi") { | |||
| 791 | - | // VAAPI encoders use rc_mode for rate control. | |||
| 792 | - | ffmpeg.AVOptSet(encCtx, "rc_mode", "VBR", 0) | |||
| 793 | - | } else if strings.HasSuffix(encoderName, "_nvenc") { | |||
| 794 | - | // NVENC uses preset and rc. | |||
| 795 | - | switch p.quality { | |||
| 796 | - | case "low": | |||
| 797 | - | ffmpeg.AVOptSet(encCtx, "preset", "p4", 0) | |||
| 798 | - | case "high": | |||
| 799 | - | ffmpeg.AVOptSet(encCtx, "preset", "p7", 0) | |||
| 800 | - | default: | |||
| 801 | - | ffmpeg.AVOptSet(encCtx, "preset", "p5", 0) | |||
| 802 | - | } | |||
| 803 | - | ffmpeg.AVOptSet(encCtx, "rc", "vbr", 0) | |||
| 804 | - | } | |||
| 805 | - | } | |||
| 806 | - | ||||
| 807 | - | // transcodeVideoPacket decodes a video packet, encodes the resulting | |||
| 808 | - | // frames, and writes them to the output. | |||
| 809 | - | func (p *pipeline) transcodeVideoPacket(pkt, frame, encPkt, decCtx, encCtx, outFmtCtx unsafe.Pointer, outVideoIdx int) error { | |||
| 810 | - | ret := ffmpeg.CodecSendPacket(decCtx, pkt) | |||
| 811 | - | if ret < 0 { | |||
| 812 | - | return fmt.Errorf("video decode send packet: %d", ret) | |||
| 813 | - | } | |||
| 814 | - | ||||
| 815 | - | for { | |||
| 816 | - | ret = ffmpeg.CodecReceiveFrame(decCtx, frame) | |||
| 817 | - | if ret < 0 { | |||
| 818 | - | // EAGAIN or EOF. | |||
| 819 | - | break | |||
| 820 | - | } | |||
| 821 | - | ||||
| 822 | - | if err := p.encodeAndWriteVideoFrame(frame, encPkt, encCtx, outFmtCtx, outVideoIdx); err != nil { | |||
| 823 | - | ffmpeg.FrameUnref(frame) | |||
| 824 | - | return err | |||
| 825 | - | } | |||
| 826 | - | ffmpeg.FrameUnref(frame) | |||
| 827 | - | } | |||
| 828 | - | ||||
| 829 | - | return nil | |||
| 830 | - | } | |||
| 831 | - | ||||
| 832 | - | // encodeAndWriteVideoFrame sends a decoded frame to the encoder and | |||
| 833 | - | // writes any resulting packets to the output. | |||
| 834 | - | func (p *pipeline) encodeAndWriteVideoFrame(frame, encPkt, encCtx, outFmtCtx unsafe.Pointer, outVideoIdx int) error { | |||
| 835 | - | encFrame := frame | |||
| 836 | - | var cleanup func() | |||
| 837 | - | if p.hwCaps.API == hwAPINameVAAPI { | |||
| 838 | - | var err error | |||
| 839 | - | encFrame, cleanup, err = uploadVideoFrameToVAAPI(frame, encCtx) | |||
| 840 | - | if err != nil { | |||
| 841 | - | return err | |||
| 842 | - | } | |||
| 843 | - | defer cleanup() | |||
| 844 | - | } | |||
| 845 | - | ||||
| 846 | - | ret := ffmpeg.CodecSendFrame(encCtx, encFrame) | |||
| 847 | - | if ret < 0 { | |||
| 848 | - | return fmt.Errorf("video encode send frame: %d", ret) | |||
| 849 | - | } | |||
| 850 | - | ||||
| 851 | - | for { | |||
| 852 | - | ret = ffmpeg.CodecReceivePacket(encCtx, encPkt) | |||
| 853 | - | if ret < 0 { | |||
| 854 | - | break | |||
| 855 | - | } | |||
| 856 | - | ||||
| 857 | - | pktSetStreamIndex(encPkt, int32(outVideoIdx)) | |||
| 858 | - | ||||
| 859 | - | outVidStream := ffmpeg.FmtCtxStream(outFmtCtx, outVideoIdx) | |||
| 860 | - | outVidStreamWrap := ffmpeg.WrapStream(outVidStream) | |||
| 861 | - | ffmpeg.PacketRescaleTs(encPkt, ffmpeg.CodecCtxTimeBase(encCtx), outVidStreamWrap.TimeBase()) | |||
| 862 | - | ||||
| 863 | - | if wret := ffmpeg.InterleavedWriteFrame(outFmtCtx, encPkt); wret < 0 { | |||
| 864 | - | ffmpeg.PacketUnref(encPkt) | |||
| 865 | - | return fmt.Errorf("video write frame: %d", wret) | |||
| 866 | - | } | |||
| 867 | - | ffmpeg.PacketUnref(encPkt) | |||
| 868 | - | } | |||
| 869 | - | ||||
| 870 | - | return nil | |||
| 871 | - | } | |||
| 872 | - | ||||
| 873 | - | func uploadVideoFrameToVAAPI(frame, encCtx unsafe.Pointer) (unsafe.Pointer, func(), error) { | |||
| 874 | - | if frameFormat(frame) == int32(ffmpeg.PixFmtVaapi) { | |||
| 875 | - | return frame, func() {}, nil | |||
| 876 | - | } | |||
| 877 | - | ||||
| 878 | - | hwFramesCtx := ffmpeg.CodecCtxHwFramesCtx(encCtx) | |||
| 879 | - | if hwFramesCtx == nil { | |||
| 880 | - | return nil, nil, errors.New("video encoder missing hw_frames_ctx") | |||
| 881 | - | } | |||
| 882 | - | ||||
| 883 | - | hwFrame := ffmpeg.FrameAlloc() | |||
| 884 | - | if hwFrame == nil { | |||
| 885 | - | return nil, nil, errors.New("failed to allocate VAAPI frame") | |||
| 886 | - | } | |||
| 887 | - | ||||
| 888 | - | cleanup := func() { | |||
| 889 | - | if hwFrame != nil { | |||
| 890 | - | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for frame allocations. | |||
| 891 | - | ffmpeg.FrameFree(unsafe.Pointer(&hwFrame)) | |||
| 892 | - | } | |||
| 893 | - | } | |||
| 894 | - | ||||
| 895 | - | frameSetFormat(hwFrame, int32(ffmpeg.PixFmtVaapi)) | |||
| 896 | - | frameSetWidth(hwFrame, frameWidth(frame)) | |||
| 897 | - | frameSetHeight(hwFrame, frameHeight(frame)) | |||
| 898 | - | frameSetPts(hwFrame, framePts(frame)) | |||
| 899 | - | ||||
| 900 | - | hwFramesRef := ffmpeg.BufferRef(hwFramesCtx) | |||
| 901 | - | if hwFramesRef == nil { | |||
| 902 | - | cleanup() | |||
| 903 | - | return nil, nil, errors.New("failed to ref encoder hw frames context") | |||
| 904 | - | } | |||
| 905 | - | frameSetHWFramesCtx(hwFrame, hwFramesRef) | |||
| 906 | - | ||||
| 907 | - | if ret := ffmpeg.HWFrameGetBuffer(hwFramesCtx, hwFrame, 0); ret < 0 { | |||
| 908 | - | cleanup() | |||
| 909 | - | return nil, nil, fmt.Errorf("allocate VAAPI frame buffer: %d", ret) | |||
| 910 | - | } | |||
| 911 | - | if ret := ffmpeg.HWFrameTransferData(hwFrame, frame, 0); ret < 0 { | |||
| 912 | - | cleanup() | |||
| 913 | - | return nil, nil, fmt.Errorf("upload frame to VAAPI: %d", ret) | |||
| 914 | - | } | |||
| 915 | - | ||||
| 916 | - | return hwFrame, cleanup, nil | |||
| 917 | - | } | |||
| 918 | - | ||||
| 919 | - | // averrorEOF is the AVERROR_EOF constant. | |||
| 920 | - | const averrorEOF = -('E' | 'O'<<8 | 'F'<<16 | ' '<<24) | |||
D internal/infrastructure/transcoder/pool.go +0 -111
| @@ -1,111 +0,0 @@ | |||
| 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 (*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.RWMutex | |
| 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.RLock() | |
| 87 | - | defer p.mu.RUnlock() | |
| 88 | - | return len(p.sessions) | |
| 89 | - | } | |
| 90 | - | ||
| 91 | - | // closeAll cancels and closes every active session. It collects sessions | |
| 92 | - | // under the lock, clears the map, then releases the lock before calling | |
| 93 | - | // Close on each session to avoid holding the lock during potentially | |
| 94 | - | // blocking I/O. | |
| 95 | - | func (p *sessionPool) closeAll() { | |
| 96 | - | p.mu.Lock() | |
| 97 | - | snapshot := make([]*session, 0, len(p.sessions)) | |
| 98 | - | for _, s := range p.sessions { | |
| 99 | - | snapshot = append(snapshot, s) | |
| 100 | - | } | |
| 101 | - | // Clear the map while holding the lock. | |
| 102 | - | for id := range p.sessions { | |
| 103 | - | delete(p.sessions, id) | |
| 104 | - | } | |
| 105 | - | p.mu.Unlock() | |
| 106 | - | ||
| 107 | - | // Close sessions outside the lock. | |
| 108 | - | for _, s := range snapshot { | |
| 109 | - | _ = s.Close() | |
| 110 | - | } | |
| 111 | - | } | |
| @@ -1,111 +0,0 @@ | |||||
| 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 (*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.RWMutex | |||
| 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.RLock() | |||
| 87 | - | defer p.mu.RUnlock() | |||
| 88 | - | return len(p.sessions) | |||
| 89 | - | } | |||
| 90 | - | ||||
| 91 | - | // closeAll cancels and closes every active session. It collects sessions | |||
| 92 | - | // under the lock, clears the map, then releases the lock before calling | |||
| 93 | - | // Close on each session to avoid holding the lock during potentially | |||
| 94 | - | // blocking I/O. | |||
| 95 | - | func (p *sessionPool) closeAll() { | |||
| 96 | - | p.mu.Lock() | |||
| 97 | - | snapshot := make([]*session, 0, len(p.sessions)) | |||
| 98 | - | for _, s := range p.sessions { | |||
| 99 | - | snapshot = append(snapshot, s) | |||
| 100 | - | } | |||
| 101 | - | // Clear the map while holding the lock. | |||
| 102 | - | for id := range p.sessions { | |||
| 103 | - | delete(p.sessions, id) | |||
| 104 | - | } | |||
| 105 | - | p.mu.Unlock() | |||
| 106 | - | ||||
| 107 | - | // Close sessions outside the lock. | |||
| 108 | - | for _, s := range snapshot { | |||
| 109 | - | _ = s.Close() | |||
| 110 | - | } | |||
| 111 | - | } | |||
D internal/infrastructure/transcoder/transcoder.go +0 -148
| @@ -1,148 +0,0 @@ | |||
| 1 | - | //go:build !js || !wasm | |
| 2 | - | ||
| 3 | - | package transcoder | |
| 4 | - | ||
| 5 | - | import ( | |
| 6 | - | "context" | |
| 7 | - | "errors" | |
| 8 | - | "fmt" | |
| 9 | - | "io" | |
| 10 | - | "time" | |
| 11 | - | ||
| 12 | - | "github.com/google/uuid" | |
| 13 | - | "github.com/rs/zerolog" | |
| 14 | - | ||
| 15 | - | "github.com/bnema/dumber/internal/application/port" | |
| 16 | - | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 17 | - | ) | |
| 18 | - | ||
| 19 | - | // Compile-time check: Transcoder implements port.MediaTranscoder. | |
| 20 | - | var _ port.MediaTranscoder = (*Transcoder)(nil) | |
| 21 | - | ||
| 22 | - | // Transcoder implements port.MediaTranscoder using GPU-accelerated FFmpeg | |
| 23 | - | // pipelines. It probes hardware capabilities at creation time and manages | |
| 24 | - | // a bounded pool of concurrent transcode sessions. | |
| 25 | - | type Transcoder struct { | |
| 26 | - | cfg config.TranscodingConfig | |
| 27 | - | hwCaps port.HWCapabilities | |
| 28 | - | pool *sessionPool | |
| 29 | - | logger zerolog.Logger | |
| 30 | - | } | |
| 31 | - | ||
| 32 | - | // New creates a Transcoder. It probes the GPU at creation time using the | |
| 33 | - | // configured hardware acceleration preference. If no compatible GPU | |
| 34 | - | // encoder is found, Available() will return false and Start() will fail. | |
| 35 | - | func New(cfg config.TranscodingConfig, logger *zerolog.Logger) *Transcoder { | |
| 36 | - | maxConc := cfg.MaxConcurrent | |
| 37 | - | if maxConc <= 0 { | |
| 38 | - | maxConc = 2 | |
| 39 | - | } | |
| 40 | - | ||
| 41 | - | // Work on a local copy to avoid mutating a potentially shared config. | |
| 42 | - | localCfg := cfg | |
| 43 | - | ||
| 44 | - | if localCfg.Quality == "" { | |
| 45 | - | localCfg.Quality = "medium" | |
| 46 | - | } | |
| 47 | - | ||
| 48 | - | hwaccel := localCfg.HWAccel | |
| 49 | - | if hwaccel == "" { | |
| 50 | - | hwaccel = "auto" | |
| 51 | - | } | |
| 52 | - | ||
| 53 | - | l := logger.With().Str("component", "transcoder").Logger() | |
| 54 | - | ||
| 55 | - | hwCaps := ProbeGPU(hwaccel, &l) | |
| 56 | - | if len(hwCaps.Encoders) > 0 { | |
| 57 | - | l.Info(). | |
| 58 | - | Str("api", hwCaps.API). | |
| 59 | - | Strs("encoders", hwCaps.Encoders). | |
| 60 | - | Strs("decoders", hwCaps.Decoders). | |
| 61 | - | Int("max_concurrent", maxConc). | |
| 62 | - | Msg("GPU transcoding available") | |
| 63 | - | } else { | |
| 64 | - | l.Warn().Msg("no GPU encoder found, transcoding will be unavailable") | |
| 65 | - | } | |
| 66 | - | ||
| 67 | - | return &Transcoder{ | |
| 68 | - | cfg: localCfg, | |
| 69 | - | hwCaps: hwCaps, | |
| 70 | - | pool: newSessionPool(maxConc), | |
| 71 | - | logger: l, | |
| 72 | - | } | |
| 73 | - | } | |
| 74 | - | ||
| 75 | - | // Available returns true if a compatible GPU encoder was found during | |
| 76 | - | // hardware probing. | |
| 77 | - | func (t *Transcoder) Available() bool { | |
| 78 | - | return len(t.hwCaps.Encoders) > 0 | |
| 79 | - | } | |
| 80 | - | ||
| 81 | - | // Capabilities returns the detected GPU hardware info. | |
| 82 | - | func (t *Transcoder) Capabilities() port.HWCapabilities { | |
| 83 | - | return t.hwCaps | |
| 84 | - | } | |
| 85 | - | ||
| 86 | - | // Start begins a transcode session. It spawns a goroutine running the | |
| 87 | - | // FFmpeg pipeline and returns a TranscodeSession whose Read() streams | |
| 88 | - | // the transcoded WebM output. The session is automatically removed from | |
| 89 | - | // the pool when the pipeline completes or the context is canceled. | |
| 90 | - | func (t *Transcoder) Start(ctx context.Context, sourceURL string, headers map[string]string) (port.TranscodeSession, error) { | |
| 91 | - | if !t.Available() { | |
| 92 | - | return nil, errors.New("transcoder: no GPU encoder available") | |
| 93 | - | } | |
| 94 | - | ||
| 95 | - | // Create a cancellable context for this session. | |
| 96 | - | sessionCtx, cancel := context.WithCancel(ctx) | |
| 97 | - | ||
| 98 | - | pr, pw := io.Pipe() | |
| 99 | - | ||
| 100 | - | id := uuid.NewString() | |
| 101 | - | s := &session{ | |
| 102 | - | id: id, | |
| 103 | - | pr: pr, | |
| 104 | - | cancel: cancel, | |
| 105 | - | createdAt: time.Now(), | |
| 106 | - | } | |
| 107 | - | s.lastRead.Store(time.Now().Unix()) | |
| 108 | - | ||
| 109 | - | // Check capacity and register before spawning the goroutine. | |
| 110 | - | if err := t.pool.add(s); err != nil { | |
| 111 | - | cancel() | |
| 112 | - | _ = pr.Close() | |
| 113 | - | _ = pw.Close() | |
| 114 | - | return nil, fmt.Errorf("transcoder: %w", err) | |
| 115 | - | } | |
| 116 | - | ||
| 117 | - | p := newPipeline(id, t.hwCaps, sourceURL, headers, t.cfg.Quality, pw, t.logger) | |
| 118 | - | ||
| 119 | - | t.logger.Info(). | |
| 120 | - | Str("session_id", id). | |
| 121 | - | Str("source", sourceURL). | |
| 122 | - | Int("active_sessions", t.pool.count()). | |
| 123 | - | Msg("starting transcode session") | |
| 124 | - | ||
| 125 | - | go func() { | |
| 126 | - | defer func() { | |
| 127 | - | t.pool.remove(id) | |
| 128 | - | t.logger.Info().Str("session_id", id).Msg("transcode session finished") | |
| 129 | - | }() | |
| 130 | - | defer func() { | |
| 131 | - | if r := recover(); r != nil { | |
| 132 | - | t.logger.Error().Str("session_id", id).Interface("panic", r).Msg("transcode session panicked") | |
| 133 | - | _ = pw.CloseWithError(fmt.Errorf("transcode session panicked: %v", r)) | |
| 134 | - | } | |
| 135 | - | }() | |
| 136 | - | p.run(sessionCtx) | |
| 137 | - | }() | |
| 138 | - | ||
| 139 | - | return s, nil | |
| 140 | - | } | |
| 141 | - | ||
| 142 | - | // Close shuts down all active transcode sessions. | |
| 143 | - | func (t *Transcoder) Close() { | |
| 144 | - | t.logger.Info(). | |
| 145 | - | Int("active_sessions", t.pool.count()). | |
| 146 | - | Msg("closing all transcode sessions") | |
| 147 | - | t.pool.closeAll() | |
| 148 | - | } | |
| @@ -1,148 +0,0 @@ | |||||
| 1 | - | //go:build !js || !wasm | |||
| 2 | - | ||||
| 3 | - | package transcoder | |||
| 4 | - | ||||
| 5 | - | import ( | |||
| 6 | - | "context" | |||
| 7 | - | "errors" | |||
| 8 | - | "fmt" | |||
| 9 | - | "io" | |||
| 10 | - | "time" | |||
| 11 | - | ||||
| 12 | - | "github.com/google/uuid" | |||
| 13 | - | "github.com/rs/zerolog" | |||
| 14 | - | ||||
| 15 | - | "github.com/bnema/dumber/internal/application/port" | |||
| 16 | - | "github.com/bnema/dumber/internal/infrastructure/config" | |||
| 17 | - | ) | |||
| 18 | - | ||||
| 19 | - | // Compile-time check: Transcoder implements port.MediaTranscoder. | |||
| 20 | - | var _ port.MediaTranscoder = (*Transcoder)(nil) | |||
| 21 | - | ||||
| 22 | - | // Transcoder implements port.MediaTranscoder using GPU-accelerated FFmpeg | |||
| 23 | - | // pipelines. It probes hardware capabilities at creation time and manages | |||
| 24 | - | // a bounded pool of concurrent transcode sessions. | |||
| 25 | - | type Transcoder struct { | |||
| 26 | - | cfg config.TranscodingConfig | |||
| 27 | - | hwCaps port.HWCapabilities | |||
| 28 | - | pool *sessionPool | |||
| 29 | - | logger zerolog.Logger | |||
| 30 | - | } | |||
| 31 | - | ||||
| 32 | - | // New creates a Transcoder. It probes the GPU at creation time using the | |||
| 33 | - | // configured hardware acceleration preference. If no compatible GPU | |||
| 34 | - | // encoder is found, Available() will return false and Start() will fail. | |||
| 35 | - | func New(cfg config.TranscodingConfig, logger *zerolog.Logger) *Transcoder { | |||
| 36 | - | maxConc := cfg.MaxConcurrent | |||
| 37 | - | if maxConc <= 0 { | |||
| 38 | - | maxConc = 2 | |||
| 39 | - | } | |||
| 40 | - | ||||
| 41 | - | // Work on a local copy to avoid mutating a potentially shared config. | |||
| 42 | - | localCfg := cfg | |||
| 43 | - | ||||
| 44 | - | if localCfg.Quality == "" { | |||
| 45 | - | localCfg.Quality = "medium" | |||
| 46 | - | } | |||
| 47 | - | ||||
| 48 | - | hwaccel := localCfg.HWAccel | |||
| 49 | - | if hwaccel == "" { | |||
| 50 | - | hwaccel = "auto" | |||
| 51 | - | } | |||
| 52 | - | ||||
| 53 | - | l := logger.With().Str("component", "transcoder").Logger() | |||
| 54 | - | ||||
| 55 | - | hwCaps := ProbeGPU(hwaccel, &l) | |||
| 56 | - | if len(hwCaps.Encoders) > 0 { | |||
| 57 | - | l.Info(). | |||
| 58 | - | Str("api", hwCaps.API). | |||
| 59 | - | Strs("encoders", hwCaps.Encoders). | |||
| 60 | - | Strs("decoders", hwCaps.Decoders). | |||
| 61 | - | Int("max_concurrent", maxConc). | |||
| 62 | - | Msg("GPU transcoding available") | |||
| 63 | - | } else { | |||
| 64 | - | l.Warn().Msg("no GPU encoder found, transcoding will be unavailable") | |||
| 65 | - | } | |||
| 66 | - | ||||
| 67 | - | return &Transcoder{ | |||
| 68 | - | cfg: localCfg, | |||
| 69 | - | hwCaps: hwCaps, | |||
| 70 | - | pool: newSessionPool(maxConc), | |||
| 71 | - | logger: l, | |||
| 72 | - | } | |||
| 73 | - | } | |||
| 74 | - | ||||
| 75 | - | // Available returns true if a compatible GPU encoder was found during | |||
| 76 | - | // hardware probing. | |||
| 77 | - | func (t *Transcoder) Available() bool { | |||
| 78 | - | return len(t.hwCaps.Encoders) > 0 | |||
| 79 | - | } | |||
| 80 | - | ||||
| 81 | - | // Capabilities returns the detected GPU hardware info. | |||
| 82 | - | func (t *Transcoder) Capabilities() port.HWCapabilities { | |||
| 83 | - | return t.hwCaps | |||
| 84 | - | } | |||
| 85 | - | ||||
| 86 | - | // Start begins a transcode session. It spawns a goroutine running the | |||
| 87 | - | // FFmpeg pipeline and returns a TranscodeSession whose Read() streams | |||
| 88 | - | // the transcoded WebM output. The session is automatically removed from | |||
| 89 | - | // the pool when the pipeline completes or the context is canceled. | |||
| 90 | - | func (t *Transcoder) Start(ctx context.Context, sourceURL string, headers map[string]string) (port.TranscodeSession, error) { | |||
| 91 | - | if !t.Available() { | |||
| 92 | - | return nil, errors.New("transcoder: no GPU encoder available") | |||
| 93 | - | } | |||
| 94 | - | ||||
| 95 | - | // Create a cancellable context for this session. | |||
| 96 | - | sessionCtx, cancel := context.WithCancel(ctx) | |||
| 97 | - | ||||
| 98 | - | pr, pw := io.Pipe() | |||
| 99 | - | ||||
| 100 | - | id := uuid.NewString() | |||
| 101 | - | s := &session{ | |||
| 102 | - | id: id, | |||
| 103 | - | pr: pr, | |||
| 104 | - | cancel: cancel, | |||
| 105 | - | createdAt: time.Now(), | |||
| 106 | - | } | |||
| 107 | - | s.lastRead.Store(time.Now().Unix()) | |||
| 108 | - | ||||
| 109 | - | // Check capacity and register before spawning the goroutine. | |||
| 110 | - | if err := t.pool.add(s); err != nil { | |||
| 111 | - | cancel() | |||
| 112 | - | _ = pr.Close() | |||
| 113 | - | _ = pw.Close() | |||
| 114 | - | return nil, fmt.Errorf("transcoder: %w", err) | |||
| 115 | - | } | |||
| 116 | - | ||||
| 117 | - | p := newPipeline(id, t.hwCaps, sourceURL, headers, t.cfg.Quality, pw, t.logger) | |||
| 118 | - | ||||
| 119 | - | t.logger.Info(). | |||
| 120 | - | Str("session_id", id). | |||
| 121 | - | Str("source", sourceURL). | |||
| 122 | - | Int("active_sessions", t.pool.count()). | |||
| 123 | - | Msg("starting transcode session") | |||
| 124 | - | ||||
| 125 | - | go func() { | |||
| 126 | - | defer func() { | |||
| 127 | - | t.pool.remove(id) | |||
| 128 | - | t.logger.Info().Str("session_id", id).Msg("transcode session finished") | |||
| 129 | - | }() | |||
| 130 | - | defer func() { | |||
| 131 | - | if r := recover(); r != nil { | |||
| 132 | - | t.logger.Error().Str("session_id", id).Interface("panic", r).Msg("transcode session panicked") | |||
| 133 | - | _ = pw.CloseWithError(fmt.Errorf("transcode session panicked: %v", r)) | |||
| 134 | - | } | |||
| 135 | - | }() | |||
| 136 | - | p.run(sessionCtx) | |||
| 137 | - | }() | |||
| 138 | - | ||||
| 139 | - | return s, nil | |||
| 140 | - | } | |||
| 141 | - | ||||
| 142 | - | // Close shuts down all active transcode sessions. | |||
| 143 | - | func (t *Transcoder) Close() { | |||
| 144 | - | t.logger.Info(). | |||
| 145 | - | Int("active_sessions", t.pool.count()). | |||
| 146 | - | Msg("closing all transcode sessions") | |||
| 147 | - | t.pool.closeAll() | |||
| 148 | - | } | |||
D internal/infrastructure/transcoder/transcoder_wasm.go +0 -41
| @@ -1,41 +0,0 @@ | |||
| 1 | - | //go:build js && wasm | |
| 2 | - | ||
| 3 | - | package transcoder | |
| 4 | - | ||
| 5 | - | import ( | |
| 6 | - | "context" | |
| 7 | - | "errors" | |
| 8 | - | ||
| 9 | - | "github.com/rs/zerolog" | |
| 10 | - | ||
| 11 | - | "github.com/bnema/dumber/internal/application/port" | |
| 12 | - | ) | |
| 13 | - | ||
| 14 | - | // Compile-time check: Transcoder implements port.MediaTranscoder. | |
| 15 | - | var _ port.MediaTranscoder = (*Transcoder)(nil) | |
| 16 | - | ||
| 17 | - | // Transcoder is unavailable in js/wasm builds because FFmpeg is not available. | |
| 18 | - | type Transcoder struct{} | |
| 19 | - | ||
| 20 | - | // New creates an unavailable Transcoder for js/wasm builds. | |
| 21 | - | func New(_ any, _ *zerolog.Logger) *Transcoder { | |
| 22 | - | return &Transcoder{} | |
| 23 | - | } | |
| 24 | - | ||
| 25 | - | // Available reports whether transcoding is available. | |
| 26 | - | func (*Transcoder) Available() bool { | |
| 27 | - | return false | |
| 28 | - | } | |
| 29 | - | ||
| 30 | - | // Capabilities returns empty hardware capabilities for js/wasm builds. | |
| 31 | - | func (*Transcoder) Capabilities() port.HWCapabilities { | |
| 32 | - | return port.HWCapabilities{} | |
| 33 | - | } | |
| 34 | - | ||
| 35 | - | // Start always fails for js/wasm builds. | |
| 36 | - | func (*Transcoder) Start(_ context.Context, _ string, _ map[string]string) (port.TranscodeSession, error) { | |
| 37 | - | return nil, errors.New("transcoder: FFmpeg unavailable in js/wasm") | |
| 38 | - | } | |
| 39 | - | ||
| 40 | - | // Close is a no-op for js/wasm builds. | |
| 41 | - | func (*Transcoder) Close() {} | |
| @@ -1,41 +0,0 @@ | |||||
| 1 | - | //go:build js && wasm | |||
| 2 | - | ||||
| 3 | - | package transcoder | |||
| 4 | - | ||||
| 5 | - | import ( | |||
| 6 | - | "context" | |||
| 7 | - | "errors" | |||
| 8 | - | ||||
| 9 | - | "github.com/rs/zerolog" | |||
| 10 | - | ||||
| 11 | - | "github.com/bnema/dumber/internal/application/port" | |||
| 12 | - | ) | |||
| 13 | - | ||||
| 14 | - | // Compile-time check: Transcoder implements port.MediaTranscoder. | |||
| 15 | - | var _ port.MediaTranscoder = (*Transcoder)(nil) | |||
| 16 | - | ||||
| 17 | - | // Transcoder is unavailable in js/wasm builds because FFmpeg is not available. | |||
| 18 | - | type Transcoder struct{} | |||
| 19 | - | ||||
| 20 | - | // New creates an unavailable Transcoder for js/wasm builds. | |||
| 21 | - | func New(_ any, _ *zerolog.Logger) *Transcoder { | |||
| 22 | - | return &Transcoder{} | |||
| 23 | - | } | |||
| 24 | - | ||||
| 25 | - | // Available reports whether transcoding is available. | |||
| 26 | - | func (*Transcoder) Available() bool { | |||
| 27 | - | return false | |||
| 28 | - | } | |||
| 29 | - | ||||
| 30 | - | // Capabilities returns empty hardware capabilities for js/wasm builds. | |||
| 31 | - | func (*Transcoder) Capabilities() port.HWCapabilities { | |||
| 32 | - | return port.HWCapabilities{} | |||
| 33 | - | } | |||
| 34 | - | ||||
| 35 | - | // Start always fails for js/wasm builds. | |||
| 36 | - | func (*Transcoder) Start(_ context.Context, _ string, _ map[string]string) (port.TranscodeSession, error) { | |||
| 37 | - | return nil, errors.New("transcoder: FFmpeg unavailable in js/wasm") | |||
| 38 | - | } | |||
| 39 | - | ||||
| 40 | - | // Close is a no-op for js/wasm builds. | |||
| 41 | - | func (*Transcoder) Close() {} | |||