detached 38a212c

Merge pull request #238 from bnema/feat/cef-codec-runtime-cleanup

Use codec-enabled CEF runtime and remove transcoding proxy
commit
38a212c8cf867c6ca5aec4b24e44c83f5a9f7a77
author
bnema · · 2026-04-28 04:44 UTC
committer
GitHub · · 2026-04-28 04:44 UTC
merge of
1b3ed0a + 2d8831d · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
37 files · +95 -3941
M README.md +25 -0
@@ -76,6 +76,31 @@ sudo apt install libwebkitgtk-6.0-4 libgtk-4-1 gstreamer1.0-plugins-base gstream
76 76
77 > ⚠️ Ubuntu 24.04 ships GLib 2.80, but Dumber requires GLib 2.84+. Use Arch, Fedora 41+, or the Flatpak.77 > ⚠️ Ubuntu 24.04 ships GLib 2.80, but Dumber requires GLib 2.84+. Use Arch, Fedora 41+, or the Flatpak.
78 78
79+### CEF engine (optional)
80+
81+Dumber supports a Chromium Embedded Framework engine as an alternative to WebKit.
82+To use it on Arch:
83+
84+```bash
85+sudo pacman -S cef
86+```
87+
88+CEF runtime precedence is deterministic: `CEF_DIR` wins first, then `engine.cef.cef_dir`, then `/usr/lib/cef`, then `~/.local/share/cef`.
89+Set `engine.cef.cef_dir` in config when `CEF_DIR` is unset:
90+
91+```toml
92+[engine.cef]
93+cef_dir = "/custom/path"
94+```
95+
96+Or set the higher-precedence `CEF_DIR` environment variable:
97+
98+```bash
99+CEF_DIR=/custom/path dumber browse
100+```
101+
102+Then set `engine.type = "cef"` in your config.
103+
79 ## Keyboard-Driven104 ## Keyboard-Driven
80 105
81 Four modal modes. Enter a mode, act, escape out. Vim and Zellij users already know this pattern.106 Four modal modes. Enter a mode, act, escape out. Vim and Zellij users already know this pattern.
M go.mod +1 -3
@@ -6,8 +6,7 @@ require (
6 github.com/a-h/templ v0.3.10016 github.com/a-h/templ v0.3.1001
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.12.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 +2 -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-}
M internal/bootstrap/engine.go +1 -16
@@ -15,7 +15,6 @@ import (
15 "github.com/bnema/dumber/internal/infrastructure/env"15 "github.com/bnema/dumber/internal/infrastructure/env"
16 "github.com/bnema/dumber/internal/infrastructure/handlers"16 "github.com/bnema/dumber/internal/infrastructure/handlers"
17 "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"17 "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
18- "github.com/bnema/dumber/internal/infrastructure/transcoder"
19 "github.com/bnema/dumber/internal/infrastructure/webkit"18 "github.com/bnema/dumber/internal/infrastructure/webkit"
20 "github.com/bnema/dumber/internal/ui/theme"19 "github.com/bnema/dumber/internal/ui/theme"
21 "github.com/rs/zerolog"20 "github.com/rs/zerolog"
@@ -84,12 +83,6 @@ func BuildEngine(input EngineInput) (port.Engine, error) {
84 EnableAudioHandler: cfg.Engine.CEF.EnableAudioHandler,83 EnableAudioHandler: cfg.Engine.CEF.EnableAudioHandler,
85 TraceHandlers: cfg.Engine.CEF.TraceHandlers,84 TraceHandlers: cfg.Engine.CEF.TraceHandlers,
86 }85 }
87- transcodingCfg := cef.TranscodingRuntimeConfig{
88- Enabled: cfg.Transcoding.Enabled,
89- HWAccel: cfg.Transcoding.HWAccel,
90- MaxConcurrent: cfg.Transcoding.MaxConcurrent,
91- Quality: cfg.Transcoding.Quality,
92- }
93 deps := cef.EngineDependencies{86 deps := cef.EngineDependencies{
94 RegisterHandlers: handlers.RegisterAll,87 RegisterHandlers: handlers.RegisterAll,
95 RegisterAccentHandlers: handlers.RegisterAccentHandlers,88 RegisterAccentHandlers: handlers.RegisterAccentHandlers,
@@ -99,20 +92,12 @@ func BuildEngine(input EngineInput) (port.Engine, error) {
99 ContextMenuExecutorFactory: contextMenuExecutorFactory,92 ContextMenuExecutorFactory: contextMenuExecutorFactory,
100 Clipboard: clipboardinfra.New(),93 Clipboard: clipboardinfra.New(),
101 ImageDataResolver: webkit.NewContextMenuResolver(),94 ImageDataResolver: webkit.NewContextMenuResolver(),
102- MediaClassifier: cef.MediaClassifier{
103- IsProprietaryVideoMIME: transcoder.IsProprietaryVideoMIME,
104- IsOpenVideoMIME: transcoder.IsOpenVideoMIME,
105- IsStreamingManifestMIME: transcoder.IsStreamingManifestMIME,
106- IsStreamingManifestURL: transcoder.IsStreamingManifestURL,
107- IsEagerTranscodeURL: transcoder.IsEagerTranscodeURL,
108- ParseSyntheticTranscodeURL: transcoder.ParseSyntheticTranscodeURL,
109- },
110 }95 }
111 audioFactory := audiofactory.NewAudioOutputFactory()96 audioFactory := audiofactory.NewAudioOutputFactory()
112 return cef.NewEngine(input.Ctx, opts, cef.RuntimePaths{97 return cef.NewEngine(input.Ctx, opts, cef.RuntimePaths{
113 StateRoot: profile.CEFUserDataDir(),98 StateRoot: profile.CEFUserDataDir(),
114 LogFile: profile.CEFLogFile(),99 LogFile: profile.CEFLogFile(),
115- }, cefCfg, transcodingCfg, audioFactory, deps)100+ }, cefCfg, audioFactory, deps)
116 default:101 default:
117 return nil, fmt.Errorf("unknown engine type: %q", engineType)102 return nil, fmt.Errorf("unknown engine type: %q", engineType)
118 }103 }
M internal/domain/url/startup.go +5 -2
@@ -1,5 +1,7 @@
1 package url1 package url
2 2
3+import "strings"
4+
3 const defaultBrowserStartupURL = "dumb://history"5 const defaultBrowserStartupURL = "dumb://history"
4 6
5 // DefaultBrowserStartupURL is the user-facing destination used when the browser7 // DefaultBrowserStartupURL is the user-facing destination used when the browser
@@ -12,8 +14,9 @@ func DefaultBrowserStartupURL() string {
12 // ResolveBrowserStartupURL returns the explicit URL when provided, otherwise14 // ResolveBrowserStartupURL returns the explicit URL when provided, otherwise
13 // the global user-facing browser startup URL.15 // the global user-facing browser startup URL.
14 func ResolveBrowserStartupURL(rawURL string) string {16 func ResolveBrowserStartupURL(rawURL string) string {
15- if rawURL != "" {17+ trimmed := strings.TrimSpace(rawURL)
16- return rawURL18+ if trimmed != "" {
19+ return trimmed
17 }20 }
18 return defaultBrowserStartupURL21 return defaultBrowserStartupURL
19 }22 }
M internal/domain/url/startup_test.go +7 -0
@@ -16,6 +16,13 @@ func TestResolveBrowserStartupURL_DefaultsToHistory(t *testing.T) {
16 }16 }
17 }17 }
18 18
19+func TestResolveBrowserStartupURL_WhitespaceOnlyDefaultsToHistory(t *testing.T) {
20+ got := ResolveBrowserStartupURL(" ")
21+ if got != "dumb://history" {
22+ t.Fatalf("expected default browser startup URL for whitespace-only input, got %q", got)
23+ }
24+}
25+
19 func TestDefaultBrowserStartupURL(t *testing.T) {26 func TestDefaultBrowserStartupURL(t *testing.T) {
20 got := DefaultBrowserStartupURL()27 got := DefaultBrowserStartupURL()
21 if got != "dumb://history" {28 if got != "dumb://history" {
M internal/infrastructure/cef/content_injector.go +1 -231
@@ -3,8 +3,6 @@ package cef
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6- "net/url"
7- "strings"
8 "sync"6 "sync"
9 7
10 "github.com/bnema/dumber/internal/infrastructure/webutil"8 "github.com/bnema/dumber/internal/infrastructure/webutil"
@@ -121,7 +119,7 @@ const videoDiagnosticJS = `(function(){
121 }119 }
122 120
123 // Patch attachShadow to automatically observe new shadow roots.121 // Patch attachShadow to automatically observe new shadow roots.
124- // Use a marker to avoid double-patching if redditDirectVideoJS also patches.122+ // Use a marker to avoid double-patching.
125 var origAttach = Element.prototype.__dumberOrigAttachShadow || Element.prototype.attachShadow;123 var origAttach = Element.prototype.__dumberOrigAttachShadow || Element.prototype.attachShadow;
126 if (!Element.prototype.__dumberPatched) {124 if (!Element.prototype.__dumberPatched) {
127 Element.prototype.__dumberOrigAttachShadow = origAttach;125 Element.prototype.__dumberOrigAttachShadow = origAttach;
@@ -164,227 +162,6 @@ const videoDiagnosticJS = `(function(){
164 'aac:', MediaSource ? MediaSource.isTypeSupported('audio/mp4; codecs="mp4a.40.2"') : 'n/a');162 'aac:', MediaSource ? MediaSource.isTypeSupported('audio/mp4; codecs="mp4a.40.2"') : 'n/a');
165 })();`163 })();`
166 164
167-// redditDirectVideoJS replaces Reddit's blob/MSE playback path with a direct
168-// HLS source when MP4 MSE buffers are unsupported. This lets the browser issue
169-// a normal media request that our transcoding handler can answer with WebM.
170-const redditDirectVideoJS = `(function(){
171- var tag = '[REDDIT-VIDEO-PATCH]';
172- if (!/(\.|^)reddit\.com$/i.test(location.hostname)) return;
173- if (!window.MediaSource) return;
174- if (MediaSource.isTypeSupported('video/mp4; codecs="avc1.4d401e"') &&
175- MediaSource.isTypeSupported('audio/mp4; codecs="mp4a.40.2"')) {
176- return;
177- }
178-
179- console.warn(tag, 'active for unsupported Reddit MSE playback');
180-
181- function ensureStyle() {
182- if (document.getElementById('dumber-reddit-direct-video-style')) return;
183- var style = document.createElement('style');
184- style.id = 'dumber-reddit-direct-video-style';
185- style.textContent =
186- 'shreddit-player[data-dumber-direct-video="1"] { position: relative !important; }' +
187- 'shreddit-player[data-dumber-direct-video="1"] > .dumber-direct-video {' +
188- ' position: absolute !important; inset: 0 !important; width: 100% !important; height: 100% !important;' +
189- ' object-fit: contain !important; background: #000 !important; z-index: 2147483647 !important;' +
190- ' display: block !important; }';
191- document.head.appendChild(style);
192- }
193-
194- function candidateRoots(video) {
195- var roots = [];
196- var seen = [];
197- function add(root) {
198- if (!root) return;
199- if (seen.indexOf(root) !== -1) return;
200- seen.push(root);
201- roots.push(root);
202- }
203-
204- var current = video;
205- while (current) {
206- add(current);
207- if (current.shadowRoot) add(current.shadowRoot);
208- current = current.parentElement;
209- }
210- add(document);
211- return roots;
212- }
213-
214- function findHlsSource(video) {
215- var selectors = [
216- 'source[type="application/vnd.apple.mpegURL"][src]',
217- 'source[type="application/x-mpegURL"][src]',
218- 'source[src*="HLSPlaylist.m3u8"]',
219- '[src*="HLSPlaylist.m3u8"]'
220- ];
221-
222- var roots = candidateRoots(video);
223- for (var i = 0; i < roots.length; i++) {
224- var root = roots[i];
225- if (!root.querySelector) continue;
226- for (var j = 0; j < selectors.length; j++) {
227- var el = root.querySelector(selectors[j]);
228- if (el && el.src) return el.src;
229- }
230- if (root.querySelectorAll) {
231- var all = root.querySelectorAll('*');
232- for (var k = 0; k < all.length; k++) {
233- if (all[k].shadowRoot) {
234- roots.push(all[k].shadowRoot);
235- }
236- }
237- }
238- }
239- return '';
240- }
241-
242- function buildPlaybackURL(hlsURL) {
243- return location.origin + '/__dumber__/transcode.webm?src=' + encodeURIComponent(hlsURL) +
244- '&referer=' + encodeURIComponent(location.href) +
245- '&origin=' + encodeURIComponent(location.origin);
246- }
247-
248- function getPlayerRoot(video) {
249- return video.closest('shreddit-player') || video.parentElement;
250- }
251-
252- function monitorReplacement(video) {
253- if (!video || video._dumberReplacementDiag) return;
254- video._dumberReplacementDiag = true;
255- [
256- 'loadstart', 'loadedmetadata', 'loadeddata', 'canplay', 'playing',
257- 'waiting', 'stalled', 'suspend', 'abort', 'emptied', 'error',
258- ].forEach(function(evt) {
259- video.addEventListener(evt, function() {
260- var err = video.error ? ('code=' + video.error.code + ' msg=' + video.error.message) : 'none';
261- console.warn(tag, 'replacement', evt, 'ready:', video.readyState, 'net:', video.networkState,
262- 'paused:', video.paused, 'error:', err, 'src:',
263- (video.currentSrc || video.src || '').substring(0, 120));
264- });
265- });
266- }
267-
268- function ensureReplacement(video, playbackURL) {
269- var root = getPlayerRoot(video);
270- if (!root) return null;
271- ensureStyle();
272-
273- var replacement = root.querySelector(':scope > .dumber-direct-video');
274- if (!replacement) {
275- replacement = document.createElement('video');
276- replacement.className = 'dumber-direct-video';
277- replacement.controls = true;
278- replacement.playsInline = true;
279- replacement.preload = 'auto';
280- replacement.autoplay = true;
281- replacement.muted = true;
282- replacement.loop = video.loop;
283- root.setAttribute('data-dumber-direct-video', '1');
284- root.appendChild(replacement);
285- }
286-
287- replacement.poster = video.poster || replacement.poster || '';
288- monitorReplacement(replacement);
289- if (replacement.src !== playbackURL) {
290- replacement.src = playbackURL;
291- replacement.load();
292- }
293- return replacement;
294- }
295-
296- function shouldForce(video) {
297- var current = video.currentSrc || video.src || '';
298- return current.indexOf('blob:') === 0;
299- }
300-
301- function forceDirectPlayback(video) {
302- if (!video || !shouldForce(video)) return;
303-
304- var hlsURL = findHlsSource(video);
305- if (!hlsURL) {
306- console.warn(tag, 'blob video found without HLS source');
307- return;
308- }
309- var playbackURL = buildPlaybackURL(hlsURL);
310- if (video._dumberPlaybackURL === playbackURL) return;
311- video._dumberPlaybackURL = playbackURL;
312-
313- console.warn(tag, 'forcing replacement video source:', playbackURL.substring(0, 120), 'from:', hlsURL.substring(0, 120));
314-
315- var replacement = ensureReplacement(video, playbackURL);
316- if (!replacement) return;
317-
318- var root = getPlayerRoot(video);
319- if (root && root.querySelectorAll) {
320- root.querySelectorAll('video').forEach(function(candidate) {
321- if (candidate === replacement) return;
322- try { candidate.pause(); } catch (e) {}
323- try { candidate.removeAttribute('src'); } catch (e) {}
324- try { candidate.srcObject = null; } catch (e) {}
325- try { candidate.load(); } catch (e) {}
326- candidate.style.display = 'none';
327- });
328- } else {
329- try { video.pause(); } catch (e) {}
330- try { video.removeAttribute('src'); } catch (e) {}
331- try { video.srcObject = null; } catch (e) {}
332- try { video.load(); } catch (e) {}
333- video.style.display = 'none';
334- }
335-
336- var playPromise = replacement.play();
337- if (playPromise && typeof playPromise.catch === 'function') {
338- playPromise.catch(function(err) {
339- console.warn(tag, 'replacement video.play() rejected:', err && err.message ? err.message : err);
340- });
341- }
342- }
343-
344- function patchTree(root) {
345- if (!root || !root.querySelectorAll) return;
346- root.querySelectorAll('video').forEach(forceDirectPlayback);
347- root.querySelectorAll('*').forEach(function(el) {
348- if (el.shadowRoot) patchTree(el.shadowRoot);
349- });
350- }
351-
352- new MutationObserver(function(mutations) {
353- mutations.forEach(function(mutation) {
354- if (mutation.type === 'attributes' && mutation.target && mutation.target.nodeName === 'VIDEO') {
355- forceDirectPlayback(mutation.target);
356- }
357- mutation.addedNodes.forEach(function(node) {
358- if (node.nodeName === 'VIDEO') forceDirectPlayback(node);
359- patchTree(node);
360- if (node.shadowRoot) patchTree(node.shadowRoot);
361- });
362- });
363- }).observe(document.documentElement, {
364- subtree: true,
365- childList: true,
366- attributes: true,
367- attributeFilter: ['src']
368- });
369-
370- // Chain to existing attachShadow patch (e.g. videoDiagnosticJS) instead of overwriting.
371- var prevAttach = Element.prototype.attachShadow;
372- Element.prototype.attachShadow = function(opts) {
373- var sr = prevAttach.call(this, opts);
374- patchTree(sr);
375- return sr;
376- };
377-
378- patchTree(document);
379- var pollCount = 0;
380- var maxPolls = 30;
381- var pollID = setInterval(function() {
382- patchTree(document);
383- pollCount++;
384- if (pollCount >= maxPolls) clearInterval(pollID);
385- }, 1000);
386-})();`
387-
388 // contentInjector implements port.ContentInjector for the CEF engine.165 // contentInjector implements port.ContentInjector for the CEF engine.
389 // It stores CSS strings and injects them into webviews via ExecuteJavaScript.166 // It stores CSS strings and injects them into webviews via ExecuteJavaScript.
390 // Thread-safe: InjectThemeCSS may be called from the UI thread while OnLoadEnd167 // Thread-safe: InjectThemeCSS may be called from the UI thread while OnLoadEnd
@@ -523,13 +300,6 @@ func (ci *contentInjector) onLoadEnd(wv *WebView) {
523 if ci.videoDiagnosticsEnabled {300 if ci.videoDiagnosticsEnabled {
524 wv.RunJavaScript(context.Background(), videoDiagnosticJS)301 wv.RunJavaScript(context.Background(), videoDiagnosticJS)
525 }302 }
526-
527- if parsed, err := url.Parse(uri); err == nil {
528- host := parsed.Hostname()
529- if host == "reddit.com" || strings.HasSuffix(host, ".reddit.com") {
530- wv.RunJavaScript(context.Background(), redditDirectVideoJS)
531- }
532- }
533 }303 }
534 304
535 // injectCSS injects a CSS string as a <style> element via JavaScript.305 // injectCSS injects a CSS string as a <style> element via JavaScript.
M internal/infrastructure/cef/engine.go +0 -45
@@ -42,7 +42,6 @@ type Engine struct {
42 clipboardTextOrchestrator port.ClipboardTextOrchestrator42 clipboardTextOrchestrator port.ClipboardTextOrchestrator
43 onClipboardCopied func(textLen int)43 onClipboardCopied func(textLen int)
44 resolver port.ImageDataResolver44 resolver port.ImageDataResolver
45- mediaClassifier MediaClassifier
46 downloadMu sync.RWMutex45 downloadMu sync.RWMutex
47 downloadHandler *downloadHandler46 downloadHandler *downloadHandler
48 alreadyRunningAppRelaunchMu sync.RWMutex47 alreadyRunningAppRelaunchMu sync.RWMutex
@@ -71,20 +70,6 @@ type Engine struct {
71 browserCreateLastHeight atomic.Int3270 browserCreateLastHeight atomic.Int32
72 browserCreateComplete atomic.Bool71 browserCreateComplete atomic.Bool
73 72
74- transcoderState transcoderStartupState
75- transcoderStateLogged atomic.Bool
76-}
77-
78-type transcoderStartupState struct {
79- ConfigEnabled bool
80- ProbeAttempted bool
81- HWAccel string
82- MaxConcurrent int
83- Quality string
84- Status string
85- API string
86- Encoders []string
87- Decoders []string
88 }73 }
89 74
90 func (e *Engine) Factory() port.WebViewFactory {75 func (e *Engine) Factory() port.WebViewFactory {
@@ -387,7 +372,6 @@ func (e *Engine) SetHandlerContext(ctx context.Context) {
387 if e.messageRouter != nil {372 if e.messageRouter != nil {
388 e.messageRouter.SetBaseContext(ctx)373 e.messageRouter.SetBaseContext(ctx)
389 }374 }
390- e.logTranscoderStartupState()
391 }375 }
392 376
393 func (e *Engine) recordContextInitialized() {377 func (e *Engine) recordContextInitialized() {
@@ -492,32 +476,3 @@ func (e *Engine) currentDownloadHandler() *downloadHandler {
492 return e.downloadHandler476 return e.downloadHandler
493 }477 }
494 478
495-func (e *Engine) logTranscoderStartupState() {
496- if e == nil || !e.transcoderStateLogged.CompareAndSwap(false, true) {
497- return
498- }
499-
500- state := e.transcoderState
501- log := logging.FromContext(e.currentContext())
502- event := log.Info().
503- Str("component", "cef-transcoder").
504- Bool("config_enabled", state.ConfigEnabled).
505- Bool("probe_attempted", state.ProbeAttempted).
506- Str("hwaccel", state.HWAccel).
507- Int("max_concurrent", state.MaxConcurrent).
508- Str("quality", state.Quality).
509- Str("status", state.Status).
510- Bool("request_handler_enabled", e.factory != nil && e.factory.transcoder != nil)
511-
512- if state.API != "" {
513- event = event.Str("api", state.API)
514- }
515- if len(state.Encoders) > 0 {
516- event = event.Strs("encoders", state.Encoders)
517- }
518- if len(state.Decoders) > 0 {
519- event = event.Strs("decoders", state.Decoders)
520- }
521-
522- event.Msg("cef: transcoder startup state")
523-}
M internal/infrastructure/cef/engine_init.go +1 -80
@@ -18,7 +18,6 @@ import (
18 "github.com/bnema/dumber/assets"18 "github.com/bnema/dumber/assets"
19 "github.com/bnema/dumber/internal/application/port"19 "github.com/bnema/dumber/internal/application/port"
20 "github.com/bnema/dumber/internal/infrastructure/config"20 "github.com/bnema/dumber/internal/infrastructure/config"
21- transcoderpkg "github.com/bnema/dumber/internal/infrastructure/transcoder"
22 "github.com/bnema/dumber/internal/logging"21 "github.com/bnema/dumber/internal/logging"
23 )22 )
24 23
@@ -37,12 +36,10 @@ func NewEngine(
37 opts port.EngineOptions,36 opts port.EngineOptions,
38 paths RuntimePaths,37 paths RuntimePaths,
39 cfg RuntimeConfig,38 cfg RuntimeConfig,
40- transcodingCfg TranscodingRuntimeConfig,
41 audioFactory port.AudioOutputFactory,39 audioFactory port.AudioOutputFactory,
42 deps EngineDependencies,40 deps EngineDependencies,
43 ) (*Engine, error) {41 ) (*Engine, error) {
44 logger := logging.FromContext(ctx)42 logger := logging.FromContext(ctx)
45- deps.MediaClassifier = deps.MediaClassifier.normalize()
46 stateRoot := resolvedStateRoot(paths.StateRoot, opts)43 stateRoot := resolvedStateRoot(paths.StateRoot, opts)
47 cleanStaleSingletonLocks(logger, stateRoot)44 cleanStaleSingletonLocks(logger, stateRoot)
48 windowlessFrameRate := config.CEFEngineConfig{WindowlessFrameRate: cfg.WindowlessFrameRate}.CEFWindowlessFrameRate()45 windowlessFrameRate := config.CEFEngineConfig{WindowlessFrameRate: cfg.WindowlessFrameRate}.CEFWindowlessFrameRate()
@@ -67,7 +64,6 @@ func NewEngine(
67 ctxMenuExecutorFactory: deps.ContextMenuExecutorFactory,64 ctxMenuExecutorFactory: deps.ContextMenuExecutorFactory,
68 clipboard: deps.Clipboard,65 clipboard: deps.Clipboard,
69 resolver: deps.ImageDataResolver,66 resolver: deps.ImageDataResolver,
70- mediaClassifier: deps.MediaClassifier,
71 }67 }
72 68
73 logger.Info().69 logger.Info().
@@ -86,12 +82,9 @@ func NewEngine(
86 return wireEngine(82 return wireEngine(
87 ctx,83 ctx,
88 eng,84 eng,
89- cfg,
90- transcodingCfg,
91 windowlessFrameRate,85 windowlessFrameRate,
92 audioFactory,86 audioFactory,
93 logger,87 logger,
94- deps.MediaClassifier,
95 deps.CurrentConfigPayload,88 deps.CurrentConfigPayload,
96 deps.DefaultConfigPayload,89 deps.DefaultConfigPayload,
97 )90 )
@@ -168,9 +161,8 @@ func initializeCEF(eng *Engine, settings purecef.Settings, logger *zerolog.Logge
168 161
169 // wireEngine creates GL loader, factory, pool, and scheme handler after CEF init.162 // wireEngine creates GL loader, factory, pool, and scheme handler after CEF init.
170 func wireEngine(163 func wireEngine(
171- ctx context.Context, eng *Engine, _ RuntimeConfig, transcodingCfg TranscodingRuntimeConfig,164+ ctx context.Context, eng *Engine,
172 windowlessFrameRate int32, audioFactory port.AudioOutputFactory, logger *zerolog.Logger,165 windowlessFrameRate int32, audioFactory port.AudioOutputFactory, logger *zerolog.Logger,
173- mediaClassifier MediaClassifier,
174 currentConfigPayload func() ([]byte, error), defaultConfigPayload func() ([]byte, error),166 currentConfigPayload func() ([]byte, error), defaultConfigPayload func() ([]byte, error),
175 ) (*Engine, error) {167 ) (*Engine, error) {
176 gl, err := newGLLoader()168 gl, err := newGLLoader()
@@ -179,24 +171,19 @@ func wireEngine(
179 return nil, fmt.Errorf("GL loader: %w", err)171 return nil, fmt.Errorf("GL loader: %w", err)
180 }172 }
181 173
182- mediaTranscoder, transcoderState := initializeEngineTranscoder(transcodingCfg, logger)
183 eng.gl = gl174 eng.gl = gl
184 eng.factory = newWebViewFactory(eng, gl, webViewFactoryOptions{175 eng.factory = newWebViewFactory(eng, gl, webViewFactoryOptions{
185 scale: detectHiDPIScale(logger),176 scale: detectHiDPIScale(logger),
186 windowlessFrameRate: windowlessFrameRate,177 windowlessFrameRate: windowlessFrameRate,
187- transcoder: mediaTranscoder,
188- mediaClassifier: mediaClassifier,
189 audioOutputFactory: audioFactory,178 audioOutputFactory: audioFactory,
190 })179 })
191 eng.pool = newWebViewPool(eng.factory)180 eng.pool = newWebViewPool(eng.factory)
192 eng.contentInj = newContentInjector(eng, nil)181 eng.contentInj = newContentInjector(eng, nil)
193- eng.transcoderState = transcoderState
194 182
195 messageRouter, schemeHandler, err := newEngineSchemeHandler(183 messageRouter, schemeHandler, err := newEngineSchemeHandler(
196 ctx,184 ctx,
197 eng,185 eng,
198 logger,186 logger,
199- mediaTranscoder,
200 currentConfigPayload,187 currentConfigPayload,
201 defaultConfigPayload,188 defaultConfigPayload,
202 )189 )
@@ -211,45 +198,10 @@ func wireEngine(
211 return eng, nil198 return eng, nil
212 }199 }
213 200
214-func initializeEngineTranscoder(
215- transcodingCfg TranscodingRuntimeConfig,
216- logger *zerolog.Logger,
217-) (port.MediaTranscoder, transcoderStartupState) {
218- transcoderState := buildTranscoderStartupState(transcodingCfg)
219- if !transcodingCfg.Enabled {
220- return nil, transcoderState
221- }
222-
223- transcoderState.ProbeAttempted = true
224- tc := transcoderpkg.New(config.TranscodingConfig{
225- Enabled: transcodingCfg.Enabled,
226- HWAccel: transcodingCfg.HWAccel,
227- MaxConcurrent: transcodingCfg.MaxConcurrent,
228- Quality: transcodingCfg.Quality,
229- }, logger)
230- caps := tc.Capabilities()
231- transcoderState.API = caps.API
232- transcoderState.Encoders = append([]string(nil), caps.Encoders...)
233- transcoderState.Decoders = append([]string(nil), caps.Decoders...)
234- if !tc.Available() {
235- transcoderState.Status = "unavailable_no_compatible_gpu"
236- logger.Warn().Msg("cef: GPU transcoding enabled but no compatible GPU found — feature disabled")
237- return nil, transcoderState
238- }
239-
240- transcoderState.Status = "available"
241- logger.Info().
242- Str("api", caps.API).
243- Strs("encoders", caps.Encoders).
244- Msg("cef: GPU transcoding available")
245- return tc, transcoderState
246-}
247-
248 func newEngineSchemeHandler(201 func newEngineSchemeHandler(
249 ctx context.Context,202 ctx context.Context,
250 eng *Engine,203 eng *Engine,
251 logger *zerolog.Logger,204 logger *zerolog.Logger,
252- mediaTranscoder port.MediaTranscoder,
253 currentConfigPayload func() ([]byte, error),205 currentConfigPayload func() ([]byte, error),
254 defaultConfigPayload func() ([]byte, error),206 defaultConfigPayload func() ([]byte, error),
255 ) (*MessageRouter, *dumbSchemeHandler, error) {207 ) (*MessageRouter, *dumbSchemeHandler, error) {
@@ -257,7 +209,6 @@ func newEngineSchemeHandler(
257 schemeHandler, err := newDumbSchemeHandler(209 schemeHandler, err := newDumbSchemeHandler(
258 ctx,210 ctx,
259 messageRouter,211 messageRouter,
260- mediaTranscoder,
261 currentConfigPayload,212 currentConfigPayload,
262 defaultConfigPayload,213 defaultConfigPayload,
263 )214 )
@@ -328,36 +279,6 @@ func registerEngineSchemeFactory(
328 Msg("cef: registered internal https handler factory")279 Msg("cef: registered internal https handler factory")
329 }280 }
330 281
331-func buildTranscoderStartupState(cfg TranscodingRuntimeConfig) transcoderStartupState {
332- hwaccel := cfg.HWAccel
333- if hwaccel == "" {
334- hwaccel = "auto"
335- }
336-
337- maxConcurrent := cfg.MaxConcurrent
338- if maxConcurrent <= 0 {
339- maxConcurrent = 2
340- }
341-
342- quality := cfg.Quality
343- if quality == "" {
344- quality = "medium"
345- }
346-
347- status := "disabled_in_config"
348- if cfg.Enabled {
349- status = "configured_enabled"
350- }
351-
352- return transcoderStartupState{
353- ConfigEnabled: cfg.Enabled,
354- HWAccel: hwaccel,
355- MaxConcurrent: maxConcurrent,
356- Quality: quality,
357- Status: status,
358- }
359-}
360-
361 // getPrimaryMonitor returns the primary GDK monitor, or nil if unavailable.282 // getPrimaryMonitor returns the primary GDK monitor, or nil if unavailable.
362 func getPrimaryMonitor() *gdk.Monitor {283 func getPrimaryMonitor() *gdk.Monitor {
363 display := gdk.DisplayGetDefault()284 display := gdk.DisplayGetDefault()
M internal/infrastructure/cef/factory.go +2 -19
@@ -25,16 +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- mediaClassifier MediaClassifier
30 audioOutputFactory port.AudioOutputFactory28 audioOutputFactory port.AudioOutputFactory
31 }29 }
32 30
33 type webViewFactoryOptions struct {31 type webViewFactoryOptions struct {
34 scale int3232 scale int32
35 windowlessFrameRate int3233 windowlessFrameRate int32
36- transcoder port.MediaTranscoder
37- mediaClassifier MediaClassifier
38 audioOutputFactory port.AudioOutputFactory34 audioOutputFactory port.AudioOutputFactory
39 }35 }
40 36
@@ -64,9 +60,7 @@ func newWebViewFactory(engine *Engine, gl *glLoader, opts webViewFactoryOptions)
64 gl: gl,60 gl: gl,
65 scale: opts.scale,61 scale: opts.scale,
66 windowlessFrameRate: opts.windowlessFrameRate,62 windowlessFrameRate: opts.windowlessFrameRate,
67- transcoder: opts.transcoder,63+ audioOutputFactory: opts.audioOutputFactory,
68- mediaClassifier: opts.mediaClassifier,
69- audioOutputFactory: opts.audioOutputFactory,
70 }64 }
71 }65 }
72 66
@@ -121,19 +115,8 @@ func (f *WebViewFactory) newWebView(ctx context.Context) *WebView {
121 backgroundColor: f.bgColor.Load(),115 backgroundColor: f.bgColor.Load(),
122 }116 }
123 117
124- var transcodingHandler purecef.ResourceRequestHandler
125- if f.transcoder != nil {
126- transcodingHandler = newTranscodingRequestHandler(f.transcoder, f.mediaClassifier, func() context.Context {
127- if f.engine != nil {
128- return f.engine.currentContext()
129- }
130- return ctx
131- })
132- }
133-
134 handlers := &handlerSet{118 handlers := &handlerSet{
135- wv: wv,119+ wv: wv,
136- transcodingHandler: transcodingHandler,
137 }120 }
138 wv.handlers = handlers121 wv.handlers = handlers
139 wv.findCtrl = newFindController()122 wv.findCtrl = newFindController()
M internal/infrastructure/cef/runtime_config.go +0 -39
@@ -23,48 +23,10 @@ type RuntimeConfig struct {
23 TraceHandlers bool23 TraceHandlers bool
24 }24 }
25 25
26-type TranscodingRuntimeConfig struct {
27- Enabled bool
28- HWAccel string
29- MaxConcurrent int
30- Quality string
31-}
32-
33 type HandlerRegistrar func(context.Context, port.WebUIHandlerRouter, port.HandlerDependencies) error26 type HandlerRegistrar func(context.Context, port.WebUIHandlerRouter, port.HandlerDependencies) error
34 27
35 type AccentHandlerRegistrar func(context.Context, port.WebUIHandlerRouter, port.AccentKeyHandler) error28 type AccentHandlerRegistrar func(context.Context, port.WebUIHandlerRouter, port.AccentKeyHandler) error
36 29
37-type MediaClassifier struct {
38- IsProprietaryVideoMIME func(string) bool
39- IsOpenVideoMIME func(string) bool
40- IsStreamingManifestMIME func(string) bool
41- IsStreamingManifestURL func(string) bool
42- IsEagerTranscodeURL func(string) bool
43- ParseSyntheticTranscodeURL func(string) (string, string, string, bool)
44-}
45-
46-func (m MediaClassifier) normalize() MediaClassifier {
47- if m.IsProprietaryVideoMIME == nil {
48- m.IsProprietaryVideoMIME = func(string) bool { return false }
49- }
50- if m.IsOpenVideoMIME == nil {
51- m.IsOpenVideoMIME = func(string) bool { return false }
52- }
53- if m.IsStreamingManifestMIME == nil {
54- m.IsStreamingManifestMIME = func(string) bool { return false }
55- }
56- if m.IsStreamingManifestURL == nil {
57- m.IsStreamingManifestURL = func(string) bool { return false }
58- }
59- if m.IsEagerTranscodeURL == nil {
60- m.IsEagerTranscodeURL = func(string) bool { return false }
61- }
62- if m.ParseSyntheticTranscodeURL == nil {
63- m.ParseSyntheticTranscodeURL = func(string) (string, string, string, bool) { return "", "", "", false }
64- }
65- return m
66-}
67-
68 type EngineDependencies struct {30 type EngineDependencies struct {
69 RegisterHandlers HandlerRegistrar31 RegisterHandlers HandlerRegistrar
70 RegisterAccentHandlers AccentHandlerRegistrar32 RegisterAccentHandlers AccentHandlerRegistrar
@@ -74,5 +36,4 @@ type EngineDependencies struct {
74 ContextMenuExecutorFactory port.ContextMenuActionExecutorFactory36 ContextMenuExecutorFactory port.ContextMenuActionExecutorFactory
75 Clipboard port.Clipboard37 Clipboard port.Clipboard
76 ImageDataResolver port.ImageDataResolver38 ImageDataResolver port.ImageDataResolver
77- MediaClassifier MediaClassifier
78 }39 }
M internal/infrastructure/cef/scheme_handler.go +0 -94
@@ -10,7 +10,6 @@ import (
10 "html"10 "html"
11 "io"11 "io"
12 "io/fs"12 "io/fs"
13- "net"
14 "net/http"13 "net/http"
15 "net/url"14 "net/url"
16 "os"15 "os"
@@ -18,7 +17,6 @@ import (
18 "strconv"17 "strconv"
19 "strings"18 "strings"
20 "sync"19 "sync"
21- "time"
22 "unsafe"20 "unsafe"
23 21
24 "github.com/andybalholm/brotli"22 "github.com/andybalholm/brotli"
@@ -26,7 +24,6 @@ import (
26 24
27 "github.com/bnema/dumber/internal/application/port"25 "github.com/bnema/dumber/internal/application/port"
28 domainurl "github.com/bnema/dumber/internal/domain/url"26 domainurl "github.com/bnema/dumber/internal/domain/url"
29- "github.com/bnema/dumber/internal/infrastructure/netguard"
30 "github.com/bnema/dumber/internal/infrastructure/webutil"27 "github.com/bnema/dumber/internal/infrastructure/webutil"
31 "github.com/bnema/dumber/internal/logging"28 "github.com/bnema/dumber/internal/logging"
32 "github.com/rs/zerolog"29 "github.com/rs/zerolog"
@@ -59,7 +56,6 @@ var cefNewResourceHandler = purecef.NewResourceHandler
59 type dumbSchemeHandler struct {56 type dumbSchemeHandler struct {
60 ctx context.Context57 ctx context.Context
61 messageRouter *MessageRouter58 messageRouter *MessageRouter
62- transcoder port.MediaTranscoder
63 faviconService port.FaviconService59 faviconService port.FaviconService
64 assets embed.FS60 assets embed.FS
65 assetsSet bool61 assetsSet bool
@@ -95,7 +91,6 @@ type dumbSchemeHandler struct {
95 func newDumbSchemeHandler(91 func newDumbSchemeHandler(
96 ctx context.Context,92 ctx context.Context,
97 router *MessageRouter,93 router *MessageRouter,
98- transcoder port.MediaTranscoder,
99 currentConfigPayload func() ([]byte, error),94 currentConfigPayload func() ([]byte, error),
100 defaultConfigPayload func() ([]byte, error),95 defaultConfigPayload func() ([]byte, error),
101 ) (*dumbSchemeHandler, error) {96 ) (*dumbSchemeHandler, error) {
@@ -110,7 +105,6 @@ func newDumbSchemeHandler(
110 return &dumbSchemeHandler{105 return &dumbSchemeHandler{
111 ctx: ctx,106 ctx: ctx,
112 messageRouter: router,107 messageRouter: router,
113- transcoder: transcoder,
114 assetDir: systemviewsAssetDir,108 assetDir: systemviewsAssetDir,
115 logger: log.With().Str("component", "scheme-handler").Logger(),109 logger: log.With().Str("component", "scheme-handler").Logger(),
116 currentConfigPayload: currentConfigPayload,110 currentConfigPayload: currentConfigPayload,
@@ -227,8 +221,6 @@ func (h *dumbSchemeHandler) handleAPIGet(requestPath string, request purecef.Req
227 return denied, true221 return denied, true
228 }222 }
229 return h.handleConfigAPI(h.defaultConfigPayload), true223 return h.handleConfigAPI(h.defaultConfigPayload), true
230- case "/api/transcode":
231- return h.handleTranscodeAPI(request), true
232 case "/api/favicon":224 case "/api/favicon":
233 return h.handleFaviconAPI(request), true225 return h.handleFaviconAPI(request), true
234 default:226 default:
@@ -370,92 +362,6 @@ func isTrustedSystemviewURL(raw string) bool {
370 return isInternalPageHost(host)362 return isInternalPageHost(host)
371 }363 }
372 364
373-func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef.ResourceHandler {
374- if h.transcoder == nil {
375- return h.newAPIJSONResourceHandler(http.StatusServiceUnavailable, map[string]string{"error": "transcoder unavailable"})
376- }
377-
378- reqURL := request.GetURL()
379- parsed, err := url.Parse(reqURL)
380- if err != nil {
381- return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid request URL"})
382- }
383-
384- sourceURL := strings.TrimSpace(parsed.Query().Get("src"))
385- if sourceURL == "" {
386- return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "missing src"})
387- }
388-
389- sourceParsed, err := url.Parse(sourceURL)
390- if err != nil || sourceParsed.Host == "" || (sourceParsed.Scheme != "http" && sourceParsed.Scheme != actualInternalScheme) {
391- return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid src"})
392- }
393- headers := make(map[string]string)
394- if userAgent := request.GetHeaderByName("User-Agent"); userAgent != "" {
395- headers["User-Agent"] = userAgent
396- }
397- if referer := strings.TrimSpace(parsed.Query().Get("referer")); referer != "" {
398- headers["Referer"] = referer
399- } else if referer := request.GetReferrerURL(); referer != "" {
400- headers["Referer"] = referer
401- }
402- if origin := strings.TrimSpace(parsed.Query().Get("origin")); origin != "" {
403- headers["Origin"] = origin
404- }
405-
406- ctx, cancel := context.WithTimeout(h.ctx, 5*time.Minute)
407- h.logger.Info().
408- Str("source_url", logging.TruncateURL(sourceURL, maxSchemeTruncatedURLLength)).
409- Int("forwarded_header_count", len(headers)).
410- Msg("scheme handler returning transcoding stream")
411-
412- return cefNewResourceHandler(&transcodingResourceHandler{
413- transcoder: h.transcoder,
414- sourceURL: sourceURL,
415- headers: headers,
416- ctx: ctx,
417- cancel: cancel,
418- validateSource: func(ctx context.Context) error {
419- return validateTranscodeSourceURL(ctx, sourceParsed)
420- },
421- logf: func() zerolog.Logger {
422- return h.logger.With().Str("component", "scheme-transcoding").Logger()
423- },
424- })
425-}
426-
427-func validateTranscodeSourceURL(ctx context.Context, source *url.URL) error {
428- if source == nil || source.Host == "" {
429- return fmt.Errorf("invalid src")
430- }
431- host := source.Hostname()
432- if host == "" {
433- return fmt.Errorf("invalid src host")
434- }
435- if ip := net.ParseIP(host); ip != nil {
436- if isBlockedTranscodeIP(ip) {
437- return fmt.Errorf("private src host not allowed")
438- }
439- return nil
440- }
441-
442- lookupCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
443- defer cancel()
444- addrs, err := net.DefaultResolver.LookupIPAddr(lookupCtx, host)
445- if err != nil || len(addrs) == 0 {
446- return fmt.Errorf("src host could not be resolved")
447- }
448- for _, addr := range addrs {
449- if isBlockedTranscodeIP(addr.IP) {
450- return fmt.Errorf("private src host not allowed")
451- }
452- }
453- return nil
454-}
455-
456-func isBlockedTranscodeIP(ip net.IP) bool {
457- return netguard.IsBlockedTranscodeIP(ip)
458-}
459 365
460 func resolveConfigPayload(build func() ([]byte, error)) ([]byte, error) {366 func resolveConfigPayload(build func() ([]byte, error)) ([]byte, error) {
461 if build == nil {367 if build == nil {
M internal/infrastructure/cef/scheme_handler_test.go +36 -59
@@ -4,9 +4,7 @@ import (
4 "context"4 "context"
5 "encoding/base64"5 "encoding/base64"
6 "errors"6 "errors"
7- "net"
8 "net/http"7 "net/http"
9- "net/url"
10 "testing"8 "testing"
11 9
12 purecef "github.com/bnema/purego-cef/cef"10 purecef "github.com/bnema/purego-cef/cef"
@@ -51,6 +49,20 @@ func (panicFaviconService) EnsureDiskCache(context.Context, string) {
51 }49 }
52 func (panicFaviconService) Close() {}50 func (panicFaviconService) Close() {}
53 51
52+// noopConfigPayload returns a builder that always returns an empty JSON object.
53+func noopConfigPayload() func() ([]byte, error) {
54+ return func() ([]byte, error) { return []byte(`{}`), nil }
55+}
56+
57+// newTestDumbSchemeHandler creates a dumbSchemeHandler with noop config payloads.
58+// It calls t.Helper() and fails the test if construction errors.
59+func newTestDumbSchemeHandler(t *testing.T) *dumbSchemeHandler {
60+ t.Helper()
61+ h, err := newDumbSchemeHandler(context.Background(), nil, noopConfigPayload(), noopConfigPayload())
62+ require.NoError(t, err)
63+ return h
64+}
65+
54 func TestResolveConfigPayload_UsesInjectedBuilder(t *testing.T) {66 func TestResolveConfigPayload_UsesInjectedBuilder(t *testing.T) {
55 data, err := resolveConfigPayload(func() ([]byte, error) {67 data, err := resolveConfigPayload(func() ([]byte, error) {
56 return []byte(`{"engine_type":"cef"}`), nil68 return []byte(`{"engine_type":"cef"}`), nil
@@ -74,22 +86,21 @@ func TestResolveConfigPayload_NilBuilderFails(t *testing.T) {
74 }86 }
75 87
76 func TestNewDumbSchemeHandler_NilCurrentConfigPayloadFails(t *testing.T) {88 func TestNewDumbSchemeHandler_NilCurrentConfigPayloadFails(t *testing.T) {
77- h, err := newDumbSchemeHandler(context.Background(), nil, nil, nil, func() ([]byte, error) { return nil, nil })89+ h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return nil, nil })
78 require.Error(t, err)90 require.Error(t, err)
79 require.Nil(t, h)91 require.Nil(t, h)
80 require.Contains(t, err.Error(), "current config payload builder")92 require.Contains(t, err.Error(), "current config payload builder")
81 }93 }
82 94
83 func TestNewDumbSchemeHandler_NilDefaultConfigPayloadFails(t *testing.T) {95 func TestNewDumbSchemeHandler_NilDefaultConfigPayloadFails(t *testing.T) {
84- h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return nil, nil }, nil)96+ h, err := newDumbSchemeHandler(context.Background(), nil, func() ([]byte, error) { return nil, nil }, nil)
85 require.Error(t, err)97 require.Error(t, err)
86 require.Nil(t, h)98 require.Nil(t, h)
87 require.Contains(t, err.Error(), "default config payload builder")99 require.Contains(t, err.Error(), "default config payload builder")
88 }100 }
89 101
90 func TestRejectForbiddenAPIOrigin_AllowsInternalOrigin(t *testing.T) {102 func TestRejectForbiddenAPIOrigin_AllowsInternalOrigin(t *testing.T) {
91- h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })103+ h := newTestDumbSchemeHandler(t)
92- require.NoError(t, err)
93 104
94 request := cefmocks.NewMockRequest(t)105 request := cefmocks.NewMockRequest(t)
95 request.EXPECT().GetHeaderByName("Origin").Return(actualInternalOrigin).Once()106 request.EXPECT().GetHeaderByName("Origin").Return(actualInternalOrigin).Once()
@@ -102,8 +113,7 @@ func TestRejectForbiddenAPIOrigin_RejectsExternalOrigin(t *testing.T) {
102 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }113 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
103 defer func() { cefNewResourceHandler = oldNewResourceHandler }()114 defer func() { cefNewResourceHandler = oldNewResourceHandler }()
104 115
105- h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })116+ h := newTestDumbSchemeHandler(t)
106- require.NoError(t, err)
107 117
108 request := cefmocks.NewMockRequest(t)118 request := cefmocks.NewMockRequest(t)
109 request.EXPECT().GetHeaderByName("Origin").Return("https://evil.example").Once()119 request.EXPECT().GetHeaderByName("Origin").Return("https://evil.example").Once()
@@ -126,8 +136,7 @@ func TestRejectUntrustedConfigRequesterRequiresTrustedOriginOrReferrer(t *testin
126 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }136 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
127 defer func() { cefNewResourceHandler = oldNewResourceHandler }()137 defer func() { cefNewResourceHandler = oldNewResourceHandler }()
128 138
129- h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })139+ h := newTestDumbSchemeHandler(t)
130- require.NoError(t, err)
131 140
132 trustedOrigin := cefmocks.NewMockRequest(t)141 trustedOrigin := cefmocks.NewMockRequest(t)
133 trustedOrigin.EXPECT().GetHeaderByName("Origin").Return(actualInternalOrigin).Once()142 trustedOrigin.EXPECT().GetHeaderByName("Origin").Return(actualInternalOrigin).Once()
@@ -164,7 +173,7 @@ func TestHandleMessageAPIRequiresTrustedOriginOrReferrer(t *testing.T) {
164 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }173 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
165 defer func() { cefNewResourceHandler = oldNewResourceHandler }()174 defer func() { cefNewResourceHandler = oldNewResourceHandler }()
166 175
167- h, err := newDumbSchemeHandler(context.Background(), NewMessageRouter(context.Background()), nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })176+ h, err := newDumbSchemeHandler(context.Background(), NewMessageRouter(context.Background()), noopConfigPayload(), noopConfigPayload())
168 require.NoError(t, err)177 require.NoError(t, err)
169 178
170 trusted := cefmocks.NewMockRequest(t)179 trusted := cefmocks.NewMockRequest(t)
@@ -196,7 +205,7 @@ func TestHandleConfigAPIUsesPrivateNoCORSHeaders(t *testing.T) {
196 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }205 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
197 defer func() { cefNewResourceHandler = oldNewResourceHandler }()206 defer func() { cefNewResourceHandler = oldNewResourceHandler }()
198 207
199- h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{"ok":true}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })208+ h, err := newDumbSchemeHandler(context.Background(), nil, func() ([]byte, error) { return []byte(`{"ok":true}`), nil }, noopConfigPayload())
200 require.NoError(t, err)209 require.NoError(t, err)
201 210
202 response := cefmocks.NewMockResponse(t)211 response := cefmocks.NewMockResponse(t)
@@ -216,8 +225,7 @@ func TestHandleConfigAPIOptionsUsesPrivateNoCORSHeaders(t *testing.T) {
216 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }225 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
217 defer func() { cefNewResourceHandler = oldNewResourceHandler }()226 defer func() { cefNewResourceHandler = oldNewResourceHandler }()
218 227
219- h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })228+ h := newTestDumbSchemeHandler(t)
220- require.NoError(t, err)
221 229
222 request := cefmocks.NewMockRequest(t)230 request := cefmocks.NewMockRequest(t)
223 request.EXPECT().GetHeaderByName("Origin").Return(actualInternalOrigin).Once()231 request.EXPECT().GetHeaderByName("Origin").Return(actualInternalOrigin).Once()
@@ -256,8 +264,7 @@ func TestIsTrustedSystemviewURL(t *testing.T) {
256 }264 }
257 265
258 func TestRejectUntrustedFaviconRequesterAllowsTrustedOriginWithoutReferrer(t *testing.T) {266 func TestRejectUntrustedFaviconRequesterAllowsTrustedOriginWithoutReferrer(t *testing.T) {
259- h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })267+ h := newTestDumbSchemeHandler(t)
260- require.NoError(t, err)
261 268
262 request := cefmocks.NewMockRequest(t)269 request := cefmocks.NewMockRequest(t)
263 request.EXPECT().GetHeaderByName("Origin").Return(actualInternalOrigin).Once()270 request.EXPECT().GetHeaderByName("Origin").Return(actualInternalOrigin).Once()
@@ -270,8 +277,7 @@ func TestRejectUntrustedFaviconRequesterRejectsUntrustedOriginEvenWithTrustedRef
270 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }277 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
271 defer func() { cefNewResourceHandler = oldNewResourceHandler }()278 defer func() { cefNewResourceHandler = oldNewResourceHandler }()
272 279
273- h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })280+ h := newTestDumbSchemeHandler(t)
274- require.NoError(t, err)
275 281
276 request := cefmocks.NewMockRequest(t)282 request := cefmocks.NewMockRequest(t)
277 request.EXPECT().GetHeaderByName("Origin").Return("https://evil.example").Once()283 request.EXPECT().GetHeaderByName("Origin").Return("https://evil.example").Once()
@@ -284,8 +290,7 @@ func TestRejectUntrustedFaviconRequesterRequiresTrustedReferrerWhenOriginAbsent(
284 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }290 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
285 defer func() { cefNewResourceHandler = oldNewResourceHandler }()291 defer func() { cefNewResourceHandler = oldNewResourceHandler }()
286 292
287- h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })293+ h := newTestDumbSchemeHandler(t)
288- require.NoError(t, err)
289 294
290 trusted := cefmocks.NewMockRequest(t)295 trusted := cefmocks.NewMockRequest(t)
291 trusted.EXPECT().GetHeaderByName("Origin").Return("").Once()296 trusted.EXPECT().GetHeaderByName("Origin").Return("").Once()
@@ -304,8 +309,7 @@ func TestHandleFaviconAPIDefersFaviconDiskChecksUntilResourceOpen(t *testing.T)
304 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }309 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
305 defer func() { cefNewResourceHandler = oldNewResourceHandler }()310 defer func() { cefNewResourceHandler = oldNewResourceHandler }()
306 311
307- h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })312+ h := newTestDumbSchemeHandler(t)
308- require.NoError(t, err)
309 h.setFaviconService(panicFaviconService{})313 h.setFaviconService(panicFaviconService{})
310 314
311 request := cefmocks.NewMockRequest(t)315 request := cefmocks.NewMockRequest(t)
@@ -315,23 +319,6 @@ func TestHandleFaviconAPIDefersFaviconDiskChecksUntilResourceOpen(t *testing.T)
315 require.NotNil(t, h.handleFaviconAPI(request))319 require.NotNil(t, h.handleFaviconAPI(request))
316 }320 }
317 321
318-func TestValidateTranscodeSourceURL_RejectsPrivateHosts(t *testing.T) {
319- privateURL, err := url.Parse("http://127.0.0.1/video.mp4")
320- require.NoError(t, err)
321-
322- err = validateTranscodeSourceURL(context.Background(), privateURL)
323- require.Error(t, err)
324- require.Contains(t, err.Error(), "private src host")
325-}
326-
327-func TestIsBlockedTranscodeIPRejectsReservedInternalRanges(t *testing.T) {
328- for _, rawIP := range []string{"100.64.0.1", "198.18.0.1", "192.0.2.1", "64:ff9b::a00:1", "2001::1", "2001:db8::1", "2002:0a00:0001::1"} {
329- t.Run(rawIP, func(t *testing.T) {
330- require.True(t, isBlockedTranscodeIP(net.ParseIP(rawIP)))
331- })
332- }
333-}
334-
335 func TestReadBodyFromHeader_DecodesBase64Payload(t *testing.T) {322 func TestReadBodyFromHeader_DecodesBase64Payload(t *testing.T) {
336 request := cefmocks.NewMockRequest(t)323 request := cefmocks.NewMockRequest(t)
337 encoded := base64.StdEncoding.EncodeToString([]byte(`{"text":"copied from js"}`))324 encoded := base64.StdEncoding.EncodeToString([]byte(`{"text":"copied from js"}`))
@@ -361,8 +348,7 @@ func TestSchemeHandler_APIClipboardSetPathWritesClipboardPayload(t *testing.T) {
361 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }348 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
362 defer func() { cefNewResourceHandler = oldNewResourceHandler }()349 defer func() { cefNewResourceHandler = oldNewResourceHandler }()
363 350
364- h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })351+ h := newTestDumbSchemeHandler(t)
365- require.NoError(t, err)
366 352
367 const bridgeNonce = "bridge-nonce"353 const bridgeNonce = "bridge-nonce"
368 browser := cefmocks.NewMockBrowser(t)354 browser := cefmocks.NewMockBrowser(t)
@@ -398,8 +384,7 @@ func TestSchemeHandler_APIFocusSyncRejectsRequestsWithoutTrustedBridgeHeader(t *
398 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }384 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
399 defer func() { cefNewResourceHandler = oldNewResourceHandler }()385 defer func() { cefNewResourceHandler = oldNewResourceHandler }()
400 386
401- h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })387+ h := newTestDumbSchemeHandler(t)
402- require.NoError(t, err)
403 388
404 request := cefmocks.NewMockRequest(t)389 request := cefmocks.NewMockRequest(t)
405 request.EXPECT().GetHeaderByName(dumberBridgeActionHeaderName).Return("").Once()390 request.EXPECT().GetHeaderByName(dumberBridgeActionHeaderName).Return("").Once()
@@ -423,8 +408,7 @@ func TestSchemeHandler_APIFocusSyncInvokesEditableFocusCallback(t *testing.T) {
423 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }408 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
424 defer func() { cefNewResourceHandler = oldNewResourceHandler }()409 defer func() { cefNewResourceHandler = oldNewResourceHandler }()
425 410
426- h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })411+ h := newTestDumbSchemeHandler(t)
427- require.NoError(t, err)
428 412
429 const bridgeNonce = "bridge-nonce"413 const bridgeNonce = "bridge-nonce"
430 browser := cefmocks.NewMockBrowser(t)414 browser := cefmocks.NewMockBrowser(t)
@@ -459,8 +443,7 @@ func TestSchemeHandler_Create_ConceptualAPIRequestBypassesRedirect(t *testing.T)
459 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }443 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
460 defer func() { cefNewResourceHandler = oldNewResourceHandler }()444 defer func() { cefNewResourceHandler = oldNewResourceHandler }()
461 445
462- h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })446+ h := newTestDumbSchemeHandler(t)
463- require.NoError(t, err)
464 447
465 const bridgeNonce = "bridge-nonce"448 const bridgeNonce = "bridge-nonce"
466 browser := cefmocks.NewMockBrowser(t)449 browser := cefmocks.NewMockBrowser(t)
@@ -498,8 +481,7 @@ func TestSchemeHandler_APIClipboardSetRejectsInvalidBridgeNonce(t *testing.T) {
498 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }481 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
499 defer func() { cefNewResourceHandler = oldNewResourceHandler }()482 defer func() { cefNewResourceHandler = oldNewResourceHandler }()
500 483
501- h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })484+ h := newTestDumbSchemeHandler(t)
502- require.NoError(t, err)
503 485
504 browser := cefmocks.NewMockBrowser(t)486 browser := cefmocks.NewMockBrowser(t)
505 h.bridgeNonceValidator = func(_ purecef.Browser, _ string) bool { return false }487 h.bridgeNonceValidator = func(_ purecef.Browser, _ string) bool { return false }
@@ -526,8 +508,7 @@ func TestSchemeHandler_APIPopupOpenInvokesPopupCallback(t *testing.T) {
526 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }508 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
527 defer func() { cefNewResourceHandler = oldNewResourceHandler }()509 defer func() { cefNewResourceHandler = oldNewResourceHandler }()
528 510
529- h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })511+ h := newTestDumbSchemeHandler(t)
530- require.NoError(t, err)
531 512
532 const bridgeNonce = "bridge-nonce"513 const bridgeNonce = "bridge-nonce"
533 browser := cefmocks.NewMockBrowser(t)514 browser := cefmocks.NewMockBrowser(t)
@@ -570,8 +551,7 @@ func TestSchemeHandler_APIPopupNavigateInvokesPopupCallback(t *testing.T) {
570 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }551 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
571 defer func() { cefNewResourceHandler = oldNewResourceHandler }()552 defer func() { cefNewResourceHandler = oldNewResourceHandler }()
572 553
573- h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })554+ h := newTestDumbSchemeHandler(t)
574- require.NoError(t, err)
575 555
576 const bridgeNonce = "bridge-nonce"556 const bridgeNonce = "bridge-nonce"
577 browser := cefmocks.NewMockBrowser(t)557 browser := cefmocks.NewMockBrowser(t)
@@ -611,8 +591,7 @@ func TestSchemeHandler_APIPopupCloseInvokesPopupCallback(t *testing.T) {
611 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }591 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
612 defer func() { cefNewResourceHandler = oldNewResourceHandler }()592 defer func() { cefNewResourceHandler = oldNewResourceHandler }()
613 593
614- h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })594+ h := newTestDumbSchemeHandler(t)
615- require.NoError(t, err)
616 595
617 const bridgeNonce = "bridge-nonce"596 const bridgeNonce = "bridge-nonce"
618 browser := cefmocks.NewMockBrowser(t)597 browser := cefmocks.NewMockBrowser(t)
@@ -709,8 +688,7 @@ func TestSchemeHandler_APIPopupBridgeRejectsInvalidPayloads(t *testing.T) {
709 688
710 for _, test := range tests {689 for _, test := range tests {
711 t.Run(test.name, func(t *testing.T) {690 t.Run(test.name, func(t *testing.T) {
712- h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })691+ h := newTestDumbSchemeHandler(t)
713- require.NoError(t, err)
714 692
715 const bridgeNonce = "bridge-nonce"693 const bridgeNonce = "bridge-nonce"
716 browser := cefmocks.NewMockBrowser(t)694 browser := cefmocks.NewMockBrowser(t)
@@ -748,8 +726,7 @@ func TestSchemeHandler_APIOptionsReturnsCORSPreflightHeaders(t *testing.T) {
748 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }726 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
749 defer func() { cefNewResourceHandler = oldNewResourceHandler }()727 defer func() { cefNewResourceHandler = oldNewResourceHandler }()
750 728
751- h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })729+ h := newTestDumbSchemeHandler(t)
752- require.NoError(t, err)
753 730
754 request := cefmocks.NewMockRequest(t)731 request := cefmocks.NewMockRequest(t)
755 response := cefmocks.NewMockResponse(t)732 response := cefmocks.NewMockResponse(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-}
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-}
M internal/infrastructure/cef/webview_handlers.go +6 -22
@@ -8,23 +8,20 @@ import (
8 8
9 "github.com/bnema/dumber/internal/application/port"9 "github.com/bnema/dumber/internal/application/port"
10 downloadutil "github.com/bnema/dumber/internal/domain/download"10 downloadutil "github.com/bnema/dumber/internal/domain/download"
11- "github.com/bnema/dumber/internal/infrastructure/transcoder"
12 "github.com/bnema/dumber/internal/logging"11 "github.com/bnema/dumber/internal/logging"
13 )12 )
14 13
15 // Console message markers used by injected JavaScript for log filtering.14 // Console message markers used by injected JavaScript for log filtering.
16 const (15 const (
17- consoleMarkerVideoDiag = "[VIDEO-DIAG]"16+ consoleMarkerVideoDiag = "[VIDEO-DIAG]"
18- consoleMarkerRedditVideoPatch = "[REDDIT-VIDEO-PATCH]"17+ consoleMarkerAutoCopy = "[AUTO-COPY]"
19- consoleMarkerAutoCopy = "[AUTO-COPY]"
20 )18 )
21 19
22 // handlerSet implements all CEF handler interfaces and dispatches events to the20 // handlerSet implements all CEF handler interfaces and dispatches events to the
23 // owning WebView. A single struct is used so that the Client's Get*Handler21 // owning WebView. A single struct is used so that the Client's Get*Handler
24 // methods can return the same receiver, avoiding extra allocations.22 // methods can return the same receiver, avoiding extra allocations.
25 type handlerSet struct {23 type handlerSet struct {
26- wv *WebView24+ wv *WebView
27- transcodingHandler purecef.ResourceRequestHandler
28 }25 }
29 26
30 // Compile-time interface checks.27 // Compile-time interface checks.
@@ -495,7 +492,6 @@ func (h *handlerSet) OnConsoleMessage(
495 ) int32 {492 ) int32 {
496 if h.wv != nil && h.wv.ctx != nil &&493 if h.wv != nil && h.wv.ctx != nil &&
497 (strings.Contains(message, consoleMarkerVideoDiag) ||494 (strings.Contains(message, consoleMarkerVideoDiag) ||
498- strings.Contains(message, consoleMarkerRedditVideoPatch) ||
499 strings.Contains(message, consoleMarkerAutoCopy)) {495 strings.Contains(message, consoleMarkerAutoCopy)) {
500 log := logging.FromContext(h.wv.ctx).With().496 log := logging.FromContext(h.wv.ctx).With().
501 Str("component", "cef-console").497 Str("component", "cef-console").
@@ -928,7 +924,7 @@ func (h *handlerSet) OnBeforeBrowse(browser purecef.Browser, frame purecef.Frame
928 }924 }
929 925
930 logging.FromContext(h.currentContext()).Debug().926 logging.FromContext(h.currentContext()).Debug().
931- Str("url", logging.TruncateURL(url, maxTranscodingURLLength)).927+ Str("url", logging.TruncateURL(url, maxSchemeTruncatedURLLength)).
932 Msg("cef: forcing download for navigation")928 Msg("cef: forcing download for navigation")
933 929
934 host.StartDownload(url)930 host.StartDownload(url)
@@ -993,21 +989,9 @@ func (h *handlerSet) OnOpenUrlfromTab(
993 }989 }
994 990
995 func (h *handlerSet) GetResourceRequestHandler(991 func (h *handlerSet) GetResourceRequestHandler(
996- _ purecef.Browser, _ purecef.Frame, request purecef.Request,992+ _ purecef.Browser, _ purecef.Frame, _ purecef.Request,
997- _, _ int32, _ string, disableDefaultHandling *int32,993+ _, _ int32, _ string, _ *int32,
998 ) purecef.ResourceRequestHandler {994 ) purecef.ResourceRequestHandler {
999- if h.transcodingHandler != nil && request != nil && disableDefaultHandling != nil &&
1000- transcoder.IsEagerTranscodeURL(request.GetURL()) {
1001- *disableDefaultHandling = 1
1002- if h.wv != nil && h.wv.ctx != nil {
1003- logging.FromContext(h.wv.ctx).Info().
1004- Str("url", logging.TruncateURL(request.GetURL(), maxTranscodingURLLength)).
1005- Msg("cef: disabled default handling for eager transcode candidate")
1006- }
1007- }
1008- if h.transcodingHandler != nil {
1009- return h.transcodingHandler
1010- }
1011 return nil995 return nil
1012 }996 }
1013 997
M internal/infrastructure/config/defaults.go +0 -12
@@ -71,12 +71,6 @@ const (
71 defaultWebViewPoolPrewarmCount = 4 // WebViews to pre-create at startup71 defaultWebViewPoolPrewarmCount = 4 // WebViews to pre-create at startup
72 defaultCEFWindowlessFrameRate = 60 // OSR frame rate for CEF72 defaultCEFWindowlessFrameRate = 60 // OSR frame rate for CEF
73 73
74- // Transcoding defaults
75- defaultTranscodingEnabled = false
76- defaultTranscodingHWAccel = "auto"
77- defaultTranscodingMaxConcurrent = 3
78- defaultTranscodingQuality = "medium"
79-
80 // Skia threading defaults (0 = unset, -1 = unset for GPU threads)74 // Skia threading defaults (0 = unset, -1 = unset for GPU threads)
81 defaultSkiaCPUPaintingThreads = 075 defaultSkiaCPUPaintingThreads = 0
82 defaultSkiaGPUPaintingThreads = -1 // -1 means unset; 0 would disable GPU tile painting76 defaultSkiaGPUPaintingThreads = -1 // -1 means unset; 0 would disable GPU tile painting
@@ -324,12 +318,6 @@ func DefaultConfig() *Config {
324 Downloads: DownloadsConfig{318 Downloads: DownloadsConfig{
325 Path: "", // Empty = use XDG_DOWNLOAD_DIR or ~/Downloads319 Path: "", // Empty = use XDG_DOWNLOAD_DIR or ~/Downloads
326 },320 },
327- Transcoding: TranscodingConfig{
328- Enabled: defaultTranscodingEnabled,
329- HWAccel: defaultTranscodingHWAccel,
330- MaxConcurrent: defaultTranscodingMaxConcurrent,
331- Quality: defaultTranscodingQuality,
332- },
333 }321 }
334 }322 }
335 323
M internal/infrastructure/config/defaults_test.go +0 -4
@@ -13,10 +13,6 @@ func TestDefaultConfig_CoreDefaults(t *testing.T) {
13 assert.Equal(t, defaultMaxLogFiles, cfg.Logging.MaxFiles)13 assert.Equal(t, defaultMaxLogFiles, cfg.Logging.MaxFiles)
14 assert.False(t, cfg.Logging.CaptureGTKLogs)14 assert.False(t, cfg.Logging.CaptureGTKLogs)
15 assert.False(t, cfg.Media.ShowDiagnosticsOnStartup)15 assert.False(t, cfg.Media.ShowDiagnosticsOnStartup)
16- assert.False(t, cfg.Transcoding.Enabled)
17- assert.Equal(t, "auto", cfg.Transcoding.HWAccel)
18- assert.Equal(t, 3, cfg.Transcoding.MaxConcurrent)
19- assert.Equal(t, "medium", cfg.Transcoding.Quality)
20 16
21 // Engine defaults (replaces old Performance/Privacy sections)17 // Engine defaults (replaces old Performance/Privacy sections)
22 assert.Equal(t, "webkit", cfg.Engine.Type)18 assert.Equal(t, "webkit", cfg.Engine.Type)
M internal/infrastructure/config/loader.go +1 -8
@@ -417,7 +417,6 @@ func (m *Manager) setDefaults() {
417 m.setDebugDefaults(defaults)417 m.setDebugDefaults(defaults)
418 m.setAppearanceDefaults(defaults)418 m.setAppearanceDefaults(defaults)
419 m.setEngineDefaults(defaults)419 m.setEngineDefaults(defaults)
420- m.setTranscodingDefaults(defaults)
421 m.setZoomAndScaleDefaults(defaults)420 m.setZoomAndScaleDefaults(defaults)
422 m.setWorkspaceDefaults(defaults)421 m.setWorkspaceDefaults(defaults)
423 m.setContentFilteringDefaults(defaults)422 m.setContentFilteringDefaults(defaults)
@@ -611,13 +610,7 @@ func (m *Manager) setEngineDefaults(defaults *Config) {
611 m.viper.SetDefault("engine.webkit.network_process_memory_strict_threshold", wk.NetworkProcessMemoryStrictThreshold)610 m.viper.SetDefault("engine.webkit.network_process_memory_strict_threshold", wk.NetworkProcessMemoryStrictThreshold)
612 }611 }
613 612
614-func (m *Manager) setTranscodingDefaults(defaults *Config) {613+
615- tc := defaults.Transcoding
616- m.viper.SetDefault("transcoding.enabled", tc.Enabled)
617- m.viper.SetDefault("transcoding.hwaccel", tc.HWAccel)
618- m.viper.SetDefault("transcoding.max_concurrent", tc.MaxConcurrent)
619- m.viper.SetDefault("transcoding.quality", tc.Quality)
620-}
621 614
622 // New returns a new default configuration instance.615 // New returns a new default configuration instance.
623 // This is a convenience function for getting default config without the full manager.616 // This is a convenience function for getting default config without the full manager.
M internal/infrastructure/config/loader_test.go +0 -38
@@ -1,13 +1,10 @@
1 package config1 package config
2 2
3 import (3 import (
4- "os"
5- "path/filepath"
6 "testing"4 "testing"
7 5
8 "github.com/spf13/viper"6 "github.com/spf13/viper"
9 "github.com/stretchr/testify/assert"7 "github.com/stretchr/testify/assert"
10- "github.com/stretchr/testify/require"
11 )8 )
12 9
13 func TestSetEngineDefaults(t *testing.T) {10 func TestSetEngineDefaults(t *testing.T) {
@@ -27,12 +24,6 @@ func TestSetEngineDefaults(t *testing.T) {
27 assert.Equal(t, "auto", mgr.viper.GetString("engine.webkit.gl_rendering_mode"))24 assert.Equal(t, "auto", mgr.viper.GetString("engine.webkit.gl_rendering_mode"))
28 assert.False(t, mgr.viper.GetBool("engine.webkit.disable_dmabuf_renderer"))25 assert.False(t, mgr.viper.GetBool("engine.webkit.disable_dmabuf_renderer"))
29 26
30- // Transcoding defaults
31- assert.False(t, mgr.viper.GetBool("transcoding.enabled"))
32- assert.Equal(t, "auto", mgr.viper.GetString("transcoding.hwaccel"))
33- assert.Equal(t, 3, mgr.viper.GetInt("transcoding.max_concurrent"))
34- assert.Equal(t, "medium", mgr.viper.GetString("transcoding.quality"))
35-
36 // Old sections should NOT be registered as defaults27 // Old sections should NOT be registered as defaults
37 assert.Empty(t, mgr.viper.GetString("privacy.cookie_policy"))28 assert.Empty(t, mgr.viper.GetString("privacy.cookie_policy"))
38 assert.Empty(t, mgr.viper.GetString("rendering.mode"))29 assert.Empty(t, mgr.viper.GetString("rendering.mode"))
@@ -49,33 +40,4 @@ func TestNormalizeConfig_EngineCookiePolicy(t *testing.T) {
49 assert.Equal(t, CookiePolicyAlways, cfg.Engine.CookiePolicy)40 assert.Equal(t, CookiePolicyAlways, cfg.Engine.CookiePolicy)
50 }41 }
51 42
52-func TestLoad_AppliesTranscodingDefaultsWhenSectionMissing(t *testing.T) {
53- t.Setenv("XDG_CONFIG_HOME", t.TempDir())
54- t.Setenv("XDG_DATA_HOME", t.TempDir())
55- t.Setenv("XDG_STATE_HOME", t.TempDir())
56- t.Setenv("XDG_CACHE_HOME", t.TempDir())
57-
58- configDir, err := GetConfigDir()
59- require.NoError(t, err)
60-
61- err = os.MkdirAll(configDir, dirPerm)
62- require.NoError(t, err)
63 43
64- configPath := filepath.Join(configDir, "config.toml")
65- err = os.WriteFile(configPath, []byte(`
66-[engine]
67-type = "cef"
68-`), filePerm)
69- require.NoError(t, err)
70-
71- mgr, err := NewManager()
72- require.NoError(t, err)
73- require.NoError(t, mgr.Load())
74-
75- cfg := mgr.Get()
76- require.NotNil(t, cfg)
77- assert.False(t, cfg.Transcoding.Enabled)
78- assert.Equal(t, "auto", cfg.Transcoding.HWAccel)
79- assert.Equal(t, 3, cfg.Transcoding.MaxConcurrent)
80- assert.Equal(t, "medium", cfg.Transcoding.Quality)
81-}
M internal/infrastructure/config/schema.go +1 -18
@@ -110,8 +110,6 @@ type Config struct {
110 Downloads DownloadsConfig `mapstructure:"downloads" yaml:"downloads" toml:"downloads"`110 Downloads DownloadsConfig `mapstructure:"downloads" yaml:"downloads" toml:"downloads"`
111 // Engine holds engine selection and unified engine options.111 // Engine holds engine selection and unified engine options.
112 Engine EngineConfig `mapstructure:"engine" toml:"engine" yaml:"engine"`112 Engine EngineConfig `mapstructure:"engine" toml:"engine" yaml:"engine"`
113- // Transcoding controls GPU-accelerated media transcoding for proprietary codecs.
114- Transcoding TranscodingConfig `mapstructure:"transcoding" yaml:"transcoding" toml:"transcoding"`
115 }113 }
116 114
117 // CookiePolicy controls cookie acceptance behavior.115 // CookiePolicy controls cookie acceptance behavior.
@@ -384,19 +382,4 @@ type DownloadsConfig struct {
384 Path string `mapstructure:"path" yaml:"path" toml:"path"`382 Path string `mapstructure:"path" yaml:"path" toml:"path"`
385 }383 }
386 384
387-// TranscodingConfig controls GPU-accelerated media transcoding.385+
388-// When enabled, proprietary video formats (H.264, HEVC) are transcoded
389-// to open codecs (VP9, AV1) that CEF can decode natively.
390-type TranscodingConfig struct {
391- // Enabled controls whether transcoding is active (default: false).
392- Enabled bool `mapstructure:"enabled" yaml:"enabled" toml:"enabled"`
393- // HWAccel selects the hardware acceleration API: "auto", "vaapi", or "nvenc".
394- // Default: "auto" (tries VAAPI first, then CUDA).
395- HWAccel string `mapstructure:"hwaccel" yaml:"hwaccel" toml:"hwaccel"`
396- // MaxConcurrent is the maximum number of simultaneous transcode sessions.
397- // Default: 3.
398- MaxConcurrent int `mapstructure:"max_concurrent" yaml:"max_concurrent" toml:"max_concurrent"`
399- // Quality controls the encode quality preset: "low", "medium", or "high".
400- // Default: "medium".
401- Quality string `mapstructure:"quality" yaml:"quality" toml:"quality"`
402-}
M internal/infrastructure/config/schema_provider.go +6 -5
@@ -1025,11 +1025,12 @@ func (*SchemaProvider) getPerformanceKeys(defaults *Config) []entity.ConfigKeyIn
1025 func (*SchemaProvider) getRuntimeKeys(defaults *Config) []entity.ConfigKeyInfo {1025 func (*SchemaProvider) getRuntimeKeys(defaults *Config) []entity.ConfigKeyInfo {
1026 return []entity.ConfigKeyInfo{1026 return []entity.ConfigKeyInfo{
1027 {1027 {
1028- Key: "engine.cef.cef_dir",1028+ Key: "engine.cef.cef_dir",
1029- Type: "string",1029+ Type: "string",
1030- Default: defaults.Engine.CEF.CEFDir,1030+ Default: defaults.Engine.CEF.CEFDir,
1031- Description: "Custom CEF runtime directory containing libcef and Resources",1031+ Description: "Optional CEF runtime directory containing libcef.so and Resources; overrides the default " +
1032- Section: SectionRuntime,1032+ "runtime discovery order (CEF_DIR env, /usr/lib/cef, ~/.local/share/cef)",
1033+ Section: SectionRuntime,
1033 },1034 },
1034 {1035 {
1035 Key: "engine.webkit.prefix",1036 Key: "engine.webkit.prefix",
M internal/infrastructure/config/writer_test.go +0 -22
@@ -1,7 +1,6 @@
1 package config1 package config
2 2
3 import (3 import (
4- "bytes"
5 "os"4 "os"
6 "path/filepath"5 "path/filepath"
7 "strings"6 "strings"
@@ -44,27 +43,6 @@ func TestWriteConfigOrdered(t *testing.T) {
44 }43 }
45 }44 }
46 45
47- if !strings.Contains(string(content), "[transcoding]") {
48- t.Fatalf("expected transcoding section in generated config, got:\n%s", string(content))
49- }
50- // Match "enabled = false" specifically within the [transcoding] section to
51- // avoid false positives from other sections that may also have an enabled key.
52- transcodingIdx := bytes.Index(content, []byte("[transcoding]"))
53- if transcodingIdx < 0 {
54- t.Fatalf("expected transcoding section in generated config, got:\n%s", string(content))
55- }
56- afterTranscoding := content[transcodingIdx:]
57- nextSection := bytes.Index(afterTranscoding[1:], []byte("\n["))
58- var transcodingBlock []byte
59- if nextSection >= 0 {
60- transcodingBlock = afterTranscoding[:nextSection+1]
61- } else {
62- transcodingBlock = afterTranscoding
63- }
64- if !bytes.Contains(transcodingBlock, []byte("enabled = false")) {
65- t.Fatalf("expected enabled = false in [transcoding] section, got:\n%s", string(transcodingBlock))
66- }
67-
68 t.Logf("Found %d sections, all sorted", len(sections))46 t.Logf("Found %d sections, all sorted", len(sections))
69 47
70 // Print first 50 lines for debugging48 // Print first 50 lines for debugging
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-}
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-}
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-}
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-}
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-}
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-}
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-}
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-}
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-}
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)
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-}
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-}
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() {}

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