detached e6b7103

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
25 files · +23 -3303
M go.mod +0 -2
@@ -7,7 +7,6 @@ require (
77 github.com/andybalholm/brotli v1.2.1
88 github.com/bnema/purego v0.11.0-bnema.2
99 github.com/bnema/purego-cef v0.11.0
10- github.com/bnema/purego-ffmpeg v0.3.1
1110 github.com/bnema/purego-pipewire v0.1.2
1211 github.com/bnema/purego-sqlite v0.1.2
1312 github.com/bnema/puregotk v0.5.1
@@ -16,7 +15,6 @@ require (
1615 github.com/charmbracelet/lipgloss v1.1.0
1716 github.com/fsnotify/fsnotify v1.9.0
1817 github.com/godbus/dbus/v5 v5.2.2
19- github.com/google/uuid v1.6.0
2018 github.com/pelletier/go-toml/v2 v2.3.0
2119 github.com/pressly/goose/v3 v3.27.1
2220 github.com/rs/zerolog v1.35.1
@@ -7,7 +7,6 @@ require (
7 github.com/andybalholm/brotli v1.2.17 github.com/andybalholm/brotli v1.2.1
8 github.com/bnema/purego v0.11.0-bnema.28 github.com/bnema/purego v0.11.0-bnema.2
9 github.com/bnema/purego-cef v0.11.09 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.210 github.com/bnema/purego-pipewire v0.1.2
12 github.com/bnema/purego-sqlite v0.1.211 github.com/bnema/purego-sqlite v0.1.2
13 github.com/bnema/puregotk v0.5.112 github.com/bnema/puregotk v0.5.1
@@ -16,7 +15,6 @@ require (
16 github.com/charmbracelet/lipgloss v1.1.015 github.com/charmbracelet/lipgloss v1.1.0
17 github.com/fsnotify/fsnotify v1.9.016 github.com/fsnotify/fsnotify v1.9.0
18 github.com/godbus/dbus/v5 v5.2.217 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.018 github.com/pelletier/go-toml/v2 v2.3.0
21 github.com/pressly/goose/v3 v3.27.119 github.com/pressly/goose/v3 v3.27.1
22 github.com/rs/zerolog v1.35.120 github.com/rs/zerolog v1.35.1
M go.sum +0 -2 generated file: collapsed
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(
172172 return nil, fmt.Errorf("GL loader: %w", err)
173173 }
174174
175- var mediaTranscoder port.MediaTranscoder
176175 eng.gl = gl
177176 eng.factory = newWebViewFactory(eng, gl, webViewFactoryOptions{
178177 scale: detectHiDPIScale(logger),
179178 windowlessFrameRate: windowlessFrameRate,
180- transcoder: mediaTranscoder,
181179 audioOutputFactory: audioFactory,
182180 })
183181 eng.pool = newWebViewPool(eng.factory)
@@ -187,7 +185,6 @@ func wireEngine(
187185 ctx,
188186 eng,
189187 logger,
190- mediaTranscoder,
191188 currentConfigPayload,
192189 defaultConfigPayload,
193190 )
@@ -207,7 +204,6 @@ func newEngineSchemeHandler(
207204 ctx context.Context,
208205 eng *Engine,
209206 logger *zerolog.Logger,
210- mediaTranscoder port.MediaTranscoder,
211207 currentConfigPayload func() ([]byte, error),
212208 defaultConfigPayload func() ([]byte, error),
213209 ) (*MessageRouter, *dumbSchemeHandler, error) {
@@ -215,7 +211,6 @@ func newEngineSchemeHandler(
215211 schemeHandler, err := newDumbSchemeHandler(
216212 ctx,
217213 messageRouter,
218- mediaTranscoder,
219214 currentConfigPayload,
220215 defaultConfigPayload,
221216 )
@@ -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 = gl175 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 {
2525 scale int32
2626 windowlessFrameRate int32
2727 bgColor atomic.Uint32 // packed ARGB for BrowserSettings.BackgroundColor
28- transcoder port.MediaTranscoder
2928 audioOutputFactory port.AudioOutputFactory
3029 }
3130
3231 type webViewFactoryOptions struct {
3332 scale int32
3433 windowlessFrameRate int32
35- transcoder port.MediaTranscoder
3634 audioOutputFactory port.AudioOutputFactory
3735 }
3836
@@ -62,7 +60,6 @@ func newWebViewFactory(engine *Engine, gl *glLoader, opts webViewFactoryOptions)
6260 gl: gl,
6361 scale: opts.scale,
6462 windowlessFrameRate: opts.windowlessFrameRate,
65- transcoder: opts.transcoder,
6663 audioOutputFactory: opts.audioOutputFactory,
6764 }
6865 }
@@ -25,14 +25,12 @@ type WebViewFactory struct {
25 scale int3225 scale int32
26 windowlessFrameRate int3226 windowlessFrameRate int32
27 bgColor atomic.Uint32 // packed ARGB for BrowserSettings.BackgroundColor27 bgColor atomic.Uint32 // packed ARGB for BrowserSettings.BackgroundColor
28- transcoder port.MediaTranscoder
29 audioOutputFactory port.AudioOutputFactory28 audioOutputFactory port.AudioOutputFactory
30 }29 }
31 30
32 type webViewFactoryOptions struct {31 type webViewFactoryOptions struct {
33 scale int3232 scale int32
34 windowlessFrameRate int3233 windowlessFrameRate int32
35- transcoder port.MediaTranscoder
36 audioOutputFactory port.AudioOutputFactory34 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
2727
2828 type AccentHandlerRegistrar func(context.Context, port.WebUIHandlerRouter, port.AccentKeyHandler) error
2929
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-
6130 type EngineDependencies struct {
6231 RegisterHandlers HandlerRegistrar
6332 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) error28 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 HandlerRegistrar31 RegisterHandlers HandlerRegistrar
63 RegisterAccentHandlers AccentHandlerRegistrar32 RegisterAccentHandlers AccentHandlerRegistrar
M internal/infrastructure/cef/scheme_handler.go +0 -3
@@ -56,7 +56,6 @@ var cefNewResourceHandler = purecef.NewResourceHandler
5656 type dumbSchemeHandler struct {
5757 ctx context.Context
5858 messageRouter *MessageRouter
59- transcoder port.MediaTranscoder
6059 faviconService port.FaviconService
6160 assets embed.FS
6261 assetsSet bool
@@ -92,7 +91,6 @@ type dumbSchemeHandler struct {
9291 func newDumbSchemeHandler(
9392 ctx context.Context,
9493 router *MessageRouter,
95- transcoder port.MediaTranscoder,
9694 currentConfigPayload func() ([]byte, error),
9795 defaultConfigPayload func() ([]byte, error),
9896 ) (*dumbSchemeHandler, error) {
@@ -107,7 +105,6 @@ func newDumbSchemeHandler(
107105 return &dumbSchemeHandler{
108106 ctx: ctx,
109107 messageRouter: router,
110- transcoder: transcoder,
111108 assetDir: systemviewsAssetDir,
112109 logger: log.With().Str("component", "scheme-handler").Logger(),
113110 currentConfigPayload: currentConfigPayload,
@@ -56,7 +56,6 @@ var cefNewResourceHandler = purecef.NewResourceHandler
56 type dumbSchemeHandler struct {56 type dumbSchemeHandler struct {
57 ctx context.Context57 ctx context.Context
58 messageRouter *MessageRouter58 messageRouter *MessageRouter
59- transcoder port.MediaTranscoder
60 faviconService port.FaviconService59 faviconService port.FaviconService
61 assets embed.FS60 assets embed.FS
62 assetsSet bool61 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) {
7272 }
7373
7474 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 })
7676 require.Error(t, err)
7777 require.Nil(t, h)
7878 require.Contains(t, err.Error(), "current config payload builder")
7979 }
8080
8181 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)
8383 require.Error(t, err)
8484 require.Nil(t, h)
8585 require.Contains(t, err.Error(), "default config payload builder")
8686 }
8787
8888 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 })
9090 require.NoError(t, err)
9191
9292 request := cefmocks.NewMockRequest(t)
@@ -100,7 +100,7 @@ func TestRejectForbiddenAPIOrigin_RejectsExternalOrigin(t *testing.T) {
100100 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
101101 defer func() { cefNewResourceHandler = oldNewResourceHandler }()
102102
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 })
104104 require.NoError(t, err)
105105
106106 request := cefmocks.NewMockRequest(t)
@@ -124,7 +124,7 @@ func TestRejectUntrustedConfigRequesterRequiresTrustedOriginOrReferrer(t *testin
124124 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
125125 defer func() { cefNewResourceHandler = oldNewResourceHandler }()
126126
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 })
128128 require.NoError(t, err)
129129
130130 trustedOrigin := cefmocks.NewMockRequest(t)
@@ -162,7 +162,7 @@ func TestHandleMessageAPIRequiresTrustedOriginOrReferrer(t *testing.T) {
162162 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
163163 defer func() { cefNewResourceHandler = oldNewResourceHandler }()
164164
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 })
166166 require.NoError(t, err)
167167
168168 trusted := cefmocks.NewMockRequest(t)
@@ -194,7 +194,7 @@ func TestHandleConfigAPIUsesPrivateNoCORSHeaders(t *testing.T) {
194194 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
195195 defer func() { cefNewResourceHandler = oldNewResourceHandler }()
196196
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 })
198198 require.NoError(t, err)
199199
200200 response := cefmocks.NewMockResponse(t)
@@ -214,7 +214,7 @@ func TestHandleConfigAPIOptionsUsesPrivateNoCORSHeaders(t *testing.T) {
214214 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
215215 defer func() { cefNewResourceHandler = oldNewResourceHandler }()
216216
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 })
218218 require.NoError(t, err)
219219
220220 request := cefmocks.NewMockRequest(t)
@@ -254,7 +254,7 @@ func TestIsTrustedSystemviewURL(t *testing.T) {
254254 }
255255
256256 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 })
258258 require.NoError(t, err)
259259
260260 request := cefmocks.NewMockRequest(t)
@@ -268,7 +268,7 @@ func TestRejectUntrustedFaviconRequesterRejectsUntrustedOriginEvenWithTrustedRef
268268 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
269269 defer func() { cefNewResourceHandler = oldNewResourceHandler }()
270270
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 })
272272 require.NoError(t, err)
273273
274274 request := cefmocks.NewMockRequest(t)
@@ -282,7 +282,7 @@ func TestRejectUntrustedFaviconRequesterRequiresTrustedReferrerWhenOriginAbsent(
282282 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
283283 defer func() { cefNewResourceHandler = oldNewResourceHandler }()
284284
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 })
286286 require.NoError(t, err)
287287
288288 trusted := cefmocks.NewMockRequest(t)
@@ -302,7 +302,7 @@ func TestHandleFaviconAPIDefersFaviconDiskChecksUntilResourceOpen(t *testing.T)
302302 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
303303 defer func() { cefNewResourceHandler = oldNewResourceHandler }()
304304
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 })
306306 require.NoError(t, err)
307307 h.setFaviconService(panicFaviconService{})
308308
@@ -342,7 +342,7 @@ func TestSchemeHandler_APIClipboardSetPathWritesClipboardPayload(t *testing.T) {
342342 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
343343 defer func() { cefNewResourceHandler = oldNewResourceHandler }()
344344
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 })
346346 require.NoError(t, err)
347347
348348 const bridgeNonce = "bridge-nonce"
@@ -379,7 +379,7 @@ func TestSchemeHandler_APIFocusSyncRejectsRequestsWithoutTrustedBridgeHeader(t *
379379 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
380380 defer func() { cefNewResourceHandler = oldNewResourceHandler }()
381381
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 })
383383 require.NoError(t, err)
384384
385385 request := cefmocks.NewMockRequest(t)
@@ -404,7 +404,7 @@ func TestSchemeHandler_APIFocusSyncInvokesEditableFocusCallback(t *testing.T) {
404404 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
405405 defer func() { cefNewResourceHandler = oldNewResourceHandler }()
406406
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 })
408408 require.NoError(t, err)
409409
410410 const bridgeNonce = "bridge-nonce"
@@ -440,7 +440,7 @@ func TestSchemeHandler_Create_ConceptualAPIRequestBypassesRedirect(t *testing.T)
440440 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
441441 defer func() { cefNewResourceHandler = oldNewResourceHandler }()
442442
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 })
444444 require.NoError(t, err)
445445
446446 const bridgeNonce = "bridge-nonce"
@@ -479,7 +479,7 @@ func TestSchemeHandler_APIClipboardSetRejectsInvalidBridgeNonce(t *testing.T) {
479479 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
480480 defer func() { cefNewResourceHandler = oldNewResourceHandler }()
481481
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 })
483483 require.NoError(t, err)
484484
485485 browser := cefmocks.NewMockBrowser(t)
@@ -507,7 +507,7 @@ func TestSchemeHandler_APIPopupOpenInvokesPopupCallback(t *testing.T) {
507507 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
508508 defer func() { cefNewResourceHandler = oldNewResourceHandler }()
509509
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 })
511511 require.NoError(t, err)
512512
513513 const bridgeNonce = "bridge-nonce"
@@ -551,7 +551,7 @@ func TestSchemeHandler_APIPopupNavigateInvokesPopupCallback(t *testing.T) {
551551 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
552552 defer func() { cefNewResourceHandler = oldNewResourceHandler }()
553553
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 })
555555 require.NoError(t, err)
556556
557557 const bridgeNonce = "bridge-nonce"
@@ -592,7 +592,7 @@ func TestSchemeHandler_APIPopupCloseInvokesPopupCallback(t *testing.T) {
592592 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
593593 defer func() { cefNewResourceHandler = oldNewResourceHandler }()
594594
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 })
596596 require.NoError(t, err)
597597
598598 const bridgeNonce = "bridge-nonce"
@@ -690,7 +690,7 @@ func TestSchemeHandler_APIPopupBridgeRejectsInvalidPayloads(t *testing.T) {
690690
691691 for _, test := range tests {
692692 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 })
694694 require.NoError(t, err)
695695
696696 const bridgeNonce = "bridge-nonce"
@@ -729,7 +729,7 @@ func TestSchemeHandler_APIOptionsReturnsCORSPreflightHeaders(t *testing.T) {
729729 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
730730 defer func() { cefNewResourceHandler = oldNewResourceHandler }()
731731
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 })
733733 require.NoError(t, err)
734734
735735 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
926926 }
927927
928928 logging.FromContext(h.currentContext()).Debug().
929- Str("url", logging.TruncateURL(url, maxTranscodingURLLength)).
929+ Str("url", logging.TruncateURL(url, maxSchemeTruncatedURLLength)).
930930 Msg("cef: forcing download for navigation")
931931
932932 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 {
382382 Path string `mapstructure:"path" yaml:"path" toml:"path"`
383383 }
384384
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-
397385
@@ -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() {}

Keyboard shortcuts

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

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

Branches 8

Tags 42

↑↓ moveenter openesc close