Merge pull request #238 from bnema/feat/cef-codec-runtime-cleanup
Use codec-enabled CEF runtime and remove transcoding proxy
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 | 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 | 104 | ## Keyboard-Driven | |
| 80 | 105 | ||
| 81 | 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 | 6 | github.com/a-h/templ v0.3.1001 | |
| 7 | 7 | github.com/andybalholm/brotli v1.2.1 | |
| 8 | 8 | github.com/bnema/purego v0.11.0-bnema.2 | |
| 9 | - | github.com/bnema/purego-cef v0.11.0 | |
| 10 | - | github.com/bnema/purego-ffmpeg v0.3.1 | |
| 9 | + | github.com/bnema/purego-cef v0.12.0 | |
| 11 | 10 | github.com/bnema/purego-pipewire v0.1.2 | |
| 12 | 11 | github.com/bnema/purego-sqlite v0.1.2 | |
| 13 | 12 | github.com/bnema/puregotk v0.5.1 | |
| @@ -16,7 +15,6 @@ require ( | |||
| 16 | 15 | github.com/charmbracelet/lipgloss v1.1.0 | |
| 17 | 16 | github.com/fsnotify/fsnotify v1.9.0 | |
| 18 | 17 | github.com/godbus/dbus/v5 v5.2.2 | |
| 19 | - | github.com/google/uuid v1.6.0 | |
| 20 | 18 | github.com/pelletier/go-toml/v2 v2.3.0 | |
| 21 | 19 | github.com/pressly/goose/v3 v3.27.1 | |
| 22 | 20 | github.com/rs/zerolog v1.35.1 | |
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 | 15 | "github.com/bnema/dumber/internal/infrastructure/env" | |
| 16 | 16 | "github.com/bnema/dumber/internal/infrastructure/handlers" | |
| 17 | 17 | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |
| 18 | - | "github.com/bnema/dumber/internal/infrastructure/transcoder" | |
| 19 | 18 | "github.com/bnema/dumber/internal/infrastructure/webkit" | |
| 20 | 19 | "github.com/bnema/dumber/internal/ui/theme" | |
| 21 | 20 | "github.com/rs/zerolog" | |
| @@ -84,12 +83,6 @@ func BuildEngine(input EngineInput) (port.Engine, error) { | |||
| 84 | 83 | EnableAudioHandler: cfg.Engine.CEF.EnableAudioHandler, | |
| 85 | 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 | 86 | deps := cef.EngineDependencies{ | |
| 94 | 87 | RegisterHandlers: handlers.RegisterAll, | |
| 95 | 88 | RegisterAccentHandlers: handlers.RegisterAccentHandlers, | |
| @@ -99,20 +92,12 @@ func BuildEngine(input EngineInput) (port.Engine, error) { | |||
| 99 | 92 | ContextMenuExecutorFactory: contextMenuExecutorFactory, | |
| 100 | 93 | Clipboard: clipboardinfra.New(), | |
| 101 | 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 | 96 | audioFactory := audiofactory.NewAudioOutputFactory() | |
| 112 | 97 | return cef.NewEngine(input.Ctx, opts, cef.RuntimePaths{ | |
| 113 | 98 | StateRoot: profile.CEFUserDataDir(), | |
| 114 | 99 | LogFile: profile.CEFLogFile(), | |
| 115 | - | }, cefCfg, transcodingCfg, audioFactory, deps) | |
| 100 | + | }, cefCfg, audioFactory, deps) | |
| 116 | 101 | default: | |
| 117 | 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 | 1 | package url | |
| 2 | 2 | ||
| 3 | + | import "strings" | |
| 4 | + | ||
| 3 | 5 | const defaultBrowserStartupURL = "dumb://history" | |
| 4 | 6 | ||
| 5 | 7 | // DefaultBrowserStartupURL is the user-facing destination used when the browser | |
| @@ -12,8 +14,9 @@ func DefaultBrowserStartupURL() string { | |||
| 12 | 14 | // ResolveBrowserStartupURL returns the explicit URL when provided, otherwise | |
| 13 | 15 | // the global user-facing browser startup URL. | |
| 14 | 16 | func ResolveBrowserStartupURL(rawURL string) string { | |
| 15 | - | if rawURL != "" { | |
| 16 | - | return rawURL | |
| 17 | + | trimmed := strings.TrimSpace(rawURL) | |
| 18 | + | if trimmed != "" { | |
| 19 | + | return trimmed | |
| 17 | 20 | } | |
| 18 | 21 | 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 | 26 | func TestDefaultBrowserStartupURL(t *testing.T) { | |
| 20 | 27 | got := DefaultBrowserStartupURL() | |
| 21 | 28 | if got != "dumb://history" { | |
M internal/infrastructure/cef/content_injector.go +1 -231
| @@ -3,8 +3,6 @@ package cef | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | - | "net/url" | |
| 7 | - | "strings" | |
| 8 | 6 | "sync" | |
| 9 | 7 | ||
| 10 | 8 | "github.com/bnema/dumber/internal/infrastructure/webutil" | |
| @@ -121,7 +119,7 @@ const videoDiagnosticJS = `(function(){ | |||
| 121 | 119 | } | |
| 122 | 120 | ||
| 123 | 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 | 123 | var origAttach = Element.prototype.__dumberOrigAttachShadow || Element.prototype.attachShadow; | |
| 126 | 124 | if (!Element.prototype.__dumberPatched) { | |
| 127 | 125 | Element.prototype.__dumberOrigAttachShadow = origAttach; | |
| @@ -164,227 +162,6 @@ const videoDiagnosticJS = `(function(){ | |||
| 164 | 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 | 165 | // contentInjector implements port.ContentInjector for the CEF engine. | |
| 389 | 166 | // It stores CSS strings and injects them into webviews via ExecuteJavaScript. | |
| 390 | 167 | // Thread-safe: InjectThemeCSS may be called from the UI thread while OnLoadEnd | |
| @@ -523,13 +300,6 @@ func (ci *contentInjector) onLoadEnd(wv *WebView) { | |||
| 523 | 300 | if ci.videoDiagnosticsEnabled { | |
| 524 | 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 | 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 | 42 | clipboardTextOrchestrator port.ClipboardTextOrchestrator | |
| 43 | 43 | onClipboardCopied func(textLen int) | |
| 44 | 44 | resolver port.ImageDataResolver | |
| 45 | - | mediaClassifier MediaClassifier | |
| 46 | 45 | downloadMu sync.RWMutex | |
| 47 | 46 | downloadHandler *downloadHandler | |
| 48 | 47 | alreadyRunningAppRelaunchMu sync.RWMutex | |
| @@ -71,20 +70,6 @@ type Engine struct { | |||
| 71 | 70 | browserCreateLastHeight atomic.Int32 | |
| 72 | 71 | 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 | 75 | func (e *Engine) Factory() port.WebViewFactory { | |
| @@ -387,7 +372,6 @@ func (e *Engine) SetHandlerContext(ctx context.Context) { | |||
| 387 | 372 | if e.messageRouter != nil { | |
| 388 | 373 | e.messageRouter.SetBaseContext(ctx) | |
| 389 | 374 | } | |
| 390 | - | e.logTranscoderStartupState() | |
| 391 | 375 | } | |
| 392 | 376 | ||
| 393 | 377 | func (e *Engine) recordContextInitialized() { | |
| @@ -492,32 +476,3 @@ func (e *Engine) currentDownloadHandler() *downloadHandler { | |||
| 492 | 476 | 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 | 18 | "github.com/bnema/dumber/assets" | |
| 19 | 19 | "github.com/bnema/dumber/internal/application/port" | |
| 20 | 20 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 21 | - | transcoderpkg "github.com/bnema/dumber/internal/infrastructure/transcoder" | |
| 22 | 21 | "github.com/bnema/dumber/internal/logging" | |
| 23 | 22 | ) | |
| 24 | 23 | ||
| @@ -37,12 +36,10 @@ func NewEngine( | |||
| 37 | 36 | opts port.EngineOptions, | |
| 38 | 37 | paths RuntimePaths, | |
| 39 | 38 | cfg RuntimeConfig, | |
| 40 | - | transcodingCfg TranscodingRuntimeConfig, | |
| 41 | 39 | audioFactory port.AudioOutputFactory, | |
| 42 | 40 | deps EngineDependencies, | |
| 43 | 41 | ) (*Engine, error) { | |
| 44 | 42 | logger := logging.FromContext(ctx) | |
| 45 | - | deps.MediaClassifier = deps.MediaClassifier.normalize() | |
| 46 | 43 | stateRoot := resolvedStateRoot(paths.StateRoot, opts) | |
| 47 | 44 | cleanStaleSingletonLocks(logger, stateRoot) | |
| 48 | 45 | windowlessFrameRate := config.CEFEngineConfig{WindowlessFrameRate: cfg.WindowlessFrameRate}.CEFWindowlessFrameRate() | |
| @@ -67,7 +64,6 @@ func NewEngine( | |||
| 67 | 64 | ctxMenuExecutorFactory: deps.ContextMenuExecutorFactory, | |
| 68 | 65 | clipboard: deps.Clipboard, | |
| 69 | 66 | resolver: deps.ImageDataResolver, | |
| 70 | - | mediaClassifier: deps.MediaClassifier, | |
| 71 | 67 | } | |
| 72 | 68 | ||
| 73 | 69 | logger.Info(). | |
| @@ -86,12 +82,9 @@ func NewEngine( | |||
| 86 | 82 | return wireEngine( | |
| 87 | 83 | ctx, | |
| 88 | 84 | eng, | |
| 89 | - | cfg, | |
| 90 | - | transcodingCfg, | |
| 91 | 85 | windowlessFrameRate, | |
| 92 | 86 | audioFactory, | |
| 93 | 87 | logger, | |
| 94 | - | deps.MediaClassifier, | |
| 95 | 88 | deps.CurrentConfigPayload, | |
| 96 | 89 | deps.DefaultConfigPayload, | |
| 97 | 90 | ) | |
| @@ -168,9 +161,8 @@ func initializeCEF(eng *Engine, settings purecef.Settings, logger *zerolog.Logge | |||
| 168 | 161 | ||
| 169 | 162 | // wireEngine creates GL loader, factory, pool, and scheme handler after CEF init. | |
| 170 | 163 | func wireEngine( | |
| 171 | - | ctx context.Context, eng *Engine, _ RuntimeConfig, transcodingCfg TranscodingRuntimeConfig, | |
| 164 | + | ctx context.Context, eng *Engine, | |
| 172 | 165 | windowlessFrameRate int32, audioFactory port.AudioOutputFactory, logger *zerolog.Logger, | |
| 173 | - | mediaClassifier MediaClassifier, | |
| 174 | 166 | currentConfigPayload func() ([]byte, error), defaultConfigPayload func() ([]byte, error), | |
| 175 | 167 | ) (*Engine, error) { | |
| 176 | 168 | gl, err := newGLLoader() | |
| @@ -179,24 +171,19 @@ func wireEngine( | |||
| 179 | 171 | return nil, fmt.Errorf("GL loader: %w", err) | |
| 180 | 172 | } | |
| 181 | 173 | ||
| 182 | - | mediaTranscoder, transcoderState := initializeEngineTranscoder(transcodingCfg, logger) | |
| 183 | 174 | eng.gl = gl | |
| 184 | 175 | eng.factory = newWebViewFactory(eng, gl, webViewFactoryOptions{ | |
| 185 | 176 | scale: detectHiDPIScale(logger), | |
| 186 | 177 | windowlessFrameRate: windowlessFrameRate, | |
| 187 | - | transcoder: mediaTranscoder, | |
| 188 | - | mediaClassifier: mediaClassifier, | |
| 189 | 178 | audioOutputFactory: audioFactory, | |
| 190 | 179 | }) | |
| 191 | 180 | eng.pool = newWebViewPool(eng.factory) | |
| 192 | 181 | eng.contentInj = newContentInjector(eng, nil) | |
| 193 | - | eng.transcoderState = transcoderState | |
| 194 | 182 | ||
| 195 | 183 | messageRouter, schemeHandler, err := newEngineSchemeHandler( | |
| 196 | 184 | ctx, | |
| 197 | 185 | eng, | |
| 198 | 186 | logger, | |
| 199 | - | mediaTranscoder, | |
| 200 | 187 | currentConfigPayload, | |
| 201 | 188 | defaultConfigPayload, | |
| 202 | 189 | ) | |
| @@ -211,45 +198,10 @@ func wireEngine( | |||
| 211 | 198 | 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 | 201 | func newEngineSchemeHandler( | |
| 249 | 202 | ctx context.Context, | |
| 250 | 203 | eng *Engine, | |
| 251 | 204 | logger *zerolog.Logger, | |
| 252 | - | mediaTranscoder port.MediaTranscoder, | |
| 253 | 205 | currentConfigPayload func() ([]byte, error), | |
| 254 | 206 | defaultConfigPayload func() ([]byte, error), | |
| 255 | 207 | ) (*MessageRouter, *dumbSchemeHandler, error) { | |
| @@ -257,7 +209,6 @@ func newEngineSchemeHandler( | |||
| 257 | 209 | schemeHandler, err := newDumbSchemeHandler( | |
| 258 | 210 | ctx, | |
| 259 | 211 | messageRouter, | |
| 260 | - | mediaTranscoder, | |
| 261 | 212 | currentConfigPayload, | |
| 262 | 213 | defaultConfigPayload, | |
| 263 | 214 | ) | |
| @@ -328,36 +279,6 @@ func registerEngineSchemeFactory( | |||
| 328 | 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 | 282 | // getPrimaryMonitor returns the primary GDK monitor, or nil if unavailable. | |
| 362 | 283 | func getPrimaryMonitor() *gdk.Monitor { | |
| 363 | 284 | display := gdk.DisplayGetDefault() | |
M internal/infrastructure/cef/factory.go +2 -19
| @@ -25,16 +25,12 @@ type WebViewFactory struct { | |||
| 25 | 25 | scale int32 | |
| 26 | 26 | windowlessFrameRate int32 | |
| 27 | 27 | bgColor atomic.Uint32 // packed ARGB for BrowserSettings.BackgroundColor | |
| 28 | - | transcoder port.MediaTranscoder | |
| 29 | - | mediaClassifier MediaClassifier | |
| 30 | 28 | audioOutputFactory port.AudioOutputFactory | |
| 31 | 29 | } | |
| 32 | 30 | ||
| 33 | 31 | type webViewFactoryOptions struct { | |
| 34 | 32 | scale int32 | |
| 35 | 33 | windowlessFrameRate int32 | |
| 36 | - | transcoder port.MediaTranscoder | |
| 37 | - | mediaClassifier MediaClassifier | |
| 38 | 34 | audioOutputFactory port.AudioOutputFactory | |
| 39 | 35 | } | |
| 40 | 36 | ||
| @@ -64,9 +60,7 @@ func newWebViewFactory(engine *Engine, gl *glLoader, opts webViewFactoryOptions) | |||
| 64 | 60 | gl: gl, | |
| 65 | 61 | scale: opts.scale, | |
| 66 | 62 | windowlessFrameRate: opts.windowlessFrameRate, | |
| 67 | - | transcoder: opts.transcoder, | |
| 68 | - | mediaClassifier: opts.mediaClassifier, | |
| 69 | - | audioOutputFactory: opts.audioOutputFactory, | |
| 63 | + | audioOutputFactory: opts.audioOutputFactory, | |
| 70 | 64 | } | |
| 71 | 65 | } | |
| 72 | 66 | ||
| @@ -121,19 +115,8 @@ func (f *WebViewFactory) newWebView(ctx context.Context) *WebView { | |||
| 121 | 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 | 118 | handlers := &handlerSet{ | |
| 135 | - | wv: wv, | |
| 136 | - | transcodingHandler: transcodingHandler, | |
| 119 | + | wv: wv, | |
| 137 | 120 | } | |
| 138 | 121 | wv.handlers = handlers | |
| 139 | 122 | wv.findCtrl = newFindController() | |
M internal/infrastructure/cef/runtime_config.go +0 -39
| @@ -23,48 +23,10 @@ type RuntimeConfig struct { | |||
| 23 | 23 | 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 | 26 | type HandlerRegistrar func(context.Context, port.WebUIHandlerRouter, port.HandlerDependencies) error | |
| 34 | 27 | ||
| 35 | 28 | 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 | 30 | type EngineDependencies struct { | |
| 69 | 31 | RegisterHandlers HandlerRegistrar | |
| 70 | 32 | RegisterAccentHandlers AccentHandlerRegistrar | |
| @@ -74,5 +36,4 @@ type EngineDependencies struct { | |||
| 74 | 36 | ContextMenuExecutorFactory port.ContextMenuActionExecutorFactory | |
| 75 | 37 | Clipboard port.Clipboard | |
| 76 | 38 | ImageDataResolver port.ImageDataResolver | |
| 77 | - | MediaClassifier MediaClassifier | |
| 78 | 39 | } | |
M internal/infrastructure/cef/scheme_handler.go +0 -94
| @@ -10,7 +10,6 @@ import ( | |||
| 10 | 10 | "html" | |
| 11 | 11 | "io" | |
| 12 | 12 | "io/fs" | |
| 13 | - | "net" | |
| 14 | 13 | "net/http" | |
| 15 | 14 | "net/url" | |
| 16 | 15 | "os" | |
| @@ -18,7 +17,6 @@ import ( | |||
| 18 | 17 | "strconv" | |
| 19 | 18 | "strings" | |
| 20 | 19 | "sync" | |
| 21 | - | "time" | |
| 22 | 20 | "unsafe" | |
| 23 | 21 | ||
| 24 | 22 | "github.com/andybalholm/brotli" | |
| @@ -26,7 +24,6 @@ import ( | |||
| 26 | 24 | ||
| 27 | 25 | "github.com/bnema/dumber/internal/application/port" | |
| 28 | 26 | domainurl "github.com/bnema/dumber/internal/domain/url" | |
| 29 | - | "github.com/bnema/dumber/internal/infrastructure/netguard" | |
| 30 | 27 | "github.com/bnema/dumber/internal/infrastructure/webutil" | |
| 31 | 28 | "github.com/bnema/dumber/internal/logging" | |
| 32 | 29 | "github.com/rs/zerolog" | |
| @@ -59,7 +56,6 @@ var cefNewResourceHandler = purecef.NewResourceHandler | |||
| 59 | 56 | type dumbSchemeHandler struct { | |
| 60 | 57 | ctx context.Context | |
| 61 | 58 | messageRouter *MessageRouter | |
| 62 | - | transcoder port.MediaTranscoder | |
| 63 | 59 | faviconService port.FaviconService | |
| 64 | 60 | assets embed.FS | |
| 65 | 61 | assetsSet bool | |
| @@ -95,7 +91,6 @@ type dumbSchemeHandler struct { | |||
| 95 | 91 | func newDumbSchemeHandler( | |
| 96 | 92 | ctx context.Context, | |
| 97 | 93 | router *MessageRouter, | |
| 98 | - | transcoder port.MediaTranscoder, | |
| 99 | 94 | currentConfigPayload func() ([]byte, error), | |
| 100 | 95 | defaultConfigPayload func() ([]byte, error), | |
| 101 | 96 | ) (*dumbSchemeHandler, error) { | |
| @@ -110,7 +105,6 @@ func newDumbSchemeHandler( | |||
| 110 | 105 | return &dumbSchemeHandler{ | |
| 111 | 106 | ctx: ctx, | |
| 112 | 107 | messageRouter: router, | |
| 113 | - | transcoder: transcoder, | |
| 114 | 108 | assetDir: systemviewsAssetDir, | |
| 115 | 109 | logger: log.With().Str("component", "scheme-handler").Logger(), | |
| 116 | 110 | currentConfigPayload: currentConfigPayload, | |
| @@ -227,8 +221,6 @@ func (h *dumbSchemeHandler) handleAPIGet(requestPath string, request purecef.Req | |||
| 227 | 221 | return denied, true | |
| 228 | 222 | } | |
| 229 | 223 | return h.handleConfigAPI(h.defaultConfigPayload), true | |
| 230 | - | case "/api/transcode": | |
| 231 | - | return h.handleTranscodeAPI(request), true | |
| 232 | 224 | case "/api/favicon": | |
| 233 | 225 | return h.handleFaviconAPI(request), true | |
| 234 | 226 | default: | |
| @@ -370,92 +362,6 @@ func isTrustedSystemviewURL(raw string) bool { | |||
| 370 | 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 | 366 | func resolveConfigPayload(build func() ([]byte, error)) ([]byte, error) { | |
| 461 | 367 | if build == nil { | |
M internal/infrastructure/cef/scheme_handler_test.go +36 -59
| @@ -4,9 +4,7 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "encoding/base64" | |
| 6 | 6 | "errors" | |
| 7 | - | "net" | |
| 8 | 7 | "net/http" | |
| 9 | - | "net/url" | |
| 10 | 8 | "testing" | |
| 11 | 9 | ||
| 12 | 10 | purecef "github.com/bnema/purego-cef/cef" | |
| @@ -51,6 +49,20 @@ func (panicFaviconService) EnsureDiskCache(context.Context, string) { | |||
| 51 | 49 | } | |
| 52 | 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 | 66 | func TestResolveConfigPayload_UsesInjectedBuilder(t *testing.T) { | |
| 55 | 67 | data, err := resolveConfigPayload(func() ([]byte, error) { | |
| 56 | 68 | return []byte(`{"engine_type":"cef"}`), nil | |
| @@ -74,22 +86,21 @@ func TestResolveConfigPayload_NilBuilderFails(t *testing.T) { | |||
| 74 | 86 | } | |
| 75 | 87 | ||
| 76 | 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 | 90 | require.Error(t, err) | |
| 79 | 91 | require.Nil(t, h) | |
| 80 | 92 | require.Contains(t, err.Error(), "current config payload builder") | |
| 81 | 93 | } | |
| 82 | 94 | ||
| 83 | 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 | 97 | require.Error(t, err) | |
| 86 | 98 | require.Nil(t, h) | |
| 87 | 99 | require.Contains(t, err.Error(), "default config payload builder") | |
| 88 | 100 | } | |
| 89 | 101 | ||
| 90 | 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 }) | |
| 92 | - | require.NoError(t, err) | |
| 103 | + | h := newTestDumbSchemeHandler(t) | |
| 93 | 104 | ||
| 94 | 105 | request := cefmocks.NewMockRequest(t) | |
| 95 | 106 | request.EXPECT().GetHeaderByName("Origin").Return(actualInternalOrigin).Once() | |
| @@ -102,8 +113,7 @@ func TestRejectForbiddenAPIOrigin_RejectsExternalOrigin(t *testing.T) { | |||
| 102 | 113 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |
| 103 | 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 }) | |
| 106 | - | require.NoError(t, err) | |
| 116 | + | h := newTestDumbSchemeHandler(t) | |
| 107 | 117 | ||
| 108 | 118 | request := cefmocks.NewMockRequest(t) | |
| 109 | 119 | request.EXPECT().GetHeaderByName("Origin").Return("https://evil.example").Once() | |
| @@ -126,8 +136,7 @@ func TestRejectUntrustedConfigRequesterRequiresTrustedOriginOrReferrer(t *testin | |||
| 126 | 136 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |
| 127 | 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 }) | |
| 130 | - | require.NoError(t, err) | |
| 139 | + | h := newTestDumbSchemeHandler(t) | |
| 131 | 140 | ||
| 132 | 141 | trustedOrigin := cefmocks.NewMockRequest(t) | |
| 133 | 142 | trustedOrigin.EXPECT().GetHeaderByName("Origin").Return(actualInternalOrigin).Once() | |
| @@ -164,7 +173,7 @@ func TestHandleMessageAPIRequiresTrustedOriginOrReferrer(t *testing.T) { | |||
| 164 | 173 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |
| 165 | 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 | 177 | require.NoError(t, err) | |
| 169 | 178 | ||
| 170 | 179 | trusted := cefmocks.NewMockRequest(t) | |
| @@ -196,7 +205,7 @@ func TestHandleConfigAPIUsesPrivateNoCORSHeaders(t *testing.T) { | |||
| 196 | 205 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |
| 197 | 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 | 209 | require.NoError(t, err) | |
| 201 | 210 | ||
| 202 | 211 | response := cefmocks.NewMockResponse(t) | |
| @@ -216,8 +225,7 @@ func TestHandleConfigAPIOptionsUsesPrivateNoCORSHeaders(t *testing.T) { | |||
| 216 | 225 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |
| 217 | 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 }) | |
| 220 | - | require.NoError(t, err) | |
| 228 | + | h := newTestDumbSchemeHandler(t) | |
| 221 | 229 | ||
| 222 | 230 | request := cefmocks.NewMockRequest(t) | |
| 223 | 231 | request.EXPECT().GetHeaderByName("Origin").Return(actualInternalOrigin).Once() | |
| @@ -256,8 +264,7 @@ func TestIsTrustedSystemviewURL(t *testing.T) { | |||
| 256 | 264 | } | |
| 257 | 265 | ||
| 258 | 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 }) | |
| 260 | - | require.NoError(t, err) | |
| 267 | + | h := newTestDumbSchemeHandler(t) | |
| 261 | 268 | ||
| 262 | 269 | request := cefmocks.NewMockRequest(t) | |
| 263 | 270 | request.EXPECT().GetHeaderByName("Origin").Return(actualInternalOrigin).Once() | |
| @@ -270,8 +277,7 @@ func TestRejectUntrustedFaviconRequesterRejectsUntrustedOriginEvenWithTrustedRef | |||
| 270 | 277 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |
| 271 | 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 }) | |
| 274 | - | require.NoError(t, err) | |
| 280 | + | h := newTestDumbSchemeHandler(t) | |
| 275 | 281 | ||
| 276 | 282 | request := cefmocks.NewMockRequest(t) | |
| 277 | 283 | request.EXPECT().GetHeaderByName("Origin").Return("https://evil.example").Once() | |
| @@ -284,8 +290,7 @@ func TestRejectUntrustedFaviconRequesterRequiresTrustedReferrerWhenOriginAbsent( | |||
| 284 | 290 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |
| 285 | 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 }) | |
| 288 | - | require.NoError(t, err) | |
| 293 | + | h := newTestDumbSchemeHandler(t) | |
| 289 | 294 | ||
| 290 | 295 | trusted := cefmocks.NewMockRequest(t) | |
| 291 | 296 | trusted.EXPECT().GetHeaderByName("Origin").Return("").Once() | |
| @@ -304,8 +309,7 @@ func TestHandleFaviconAPIDefersFaviconDiskChecksUntilResourceOpen(t *testing.T) | |||
| 304 | 309 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |
| 305 | 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 }) | |
| 308 | - | require.NoError(t, err) | |
| 312 | + | h := newTestDumbSchemeHandler(t) | |
| 309 | 313 | h.setFaviconService(panicFaviconService{}) | |
| 310 | 314 | ||
| 311 | 315 | request := cefmocks.NewMockRequest(t) | |
| @@ -315,23 +319,6 @@ func TestHandleFaviconAPIDefersFaviconDiskChecksUntilResourceOpen(t *testing.T) | |||
| 315 | 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 | 322 | func TestReadBodyFromHeader_DecodesBase64Payload(t *testing.T) { | |
| 336 | 323 | request := cefmocks.NewMockRequest(t) | |
| 337 | 324 | encoded := base64.StdEncoding.EncodeToString([]byte(`{"text":"copied from js"}`)) | |
| @@ -361,8 +348,7 @@ func TestSchemeHandler_APIClipboardSetPathWritesClipboardPayload(t *testing.T) { | |||
| 361 | 348 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |
| 362 | 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 }) | |
| 365 | - | require.NoError(t, err) | |
| 351 | + | h := newTestDumbSchemeHandler(t) | |
| 366 | 352 | ||
| 367 | 353 | const bridgeNonce = "bridge-nonce" | |
| 368 | 354 | browser := cefmocks.NewMockBrowser(t) | |
| @@ -398,8 +384,7 @@ func TestSchemeHandler_APIFocusSyncRejectsRequestsWithoutTrustedBridgeHeader(t * | |||
| 398 | 384 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |
| 399 | 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 }) | |
| 402 | - | require.NoError(t, err) | |
| 387 | + | h := newTestDumbSchemeHandler(t) | |
| 403 | 388 | ||
| 404 | 389 | request := cefmocks.NewMockRequest(t) | |
| 405 | 390 | request.EXPECT().GetHeaderByName(dumberBridgeActionHeaderName).Return("").Once() | |
| @@ -423,8 +408,7 @@ func TestSchemeHandler_APIFocusSyncInvokesEditableFocusCallback(t *testing.T) { | |||
| 423 | 408 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |
| 424 | 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 }) | |
| 427 | - | require.NoError(t, err) | |
| 411 | + | h := newTestDumbSchemeHandler(t) | |
| 428 | 412 | ||
| 429 | 413 | const bridgeNonce = "bridge-nonce" | |
| 430 | 414 | browser := cefmocks.NewMockBrowser(t) | |
| @@ -459,8 +443,7 @@ func TestSchemeHandler_Create_ConceptualAPIRequestBypassesRedirect(t *testing.T) | |||
| 459 | 443 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |
| 460 | 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 }) | |
| 463 | - | require.NoError(t, err) | |
| 446 | + | h := newTestDumbSchemeHandler(t) | |
| 464 | 447 | ||
| 465 | 448 | const bridgeNonce = "bridge-nonce" | |
| 466 | 449 | browser := cefmocks.NewMockBrowser(t) | |
| @@ -498,8 +481,7 @@ func TestSchemeHandler_APIClipboardSetRejectsInvalidBridgeNonce(t *testing.T) { | |||
| 498 | 481 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |
| 499 | 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 }) | |
| 502 | - | require.NoError(t, err) | |
| 484 | + | h := newTestDumbSchemeHandler(t) | |
| 503 | 485 | ||
| 504 | 486 | browser := cefmocks.NewMockBrowser(t) | |
| 505 | 487 | h.bridgeNonceValidator = func(_ purecef.Browser, _ string) bool { return false } | |
| @@ -526,8 +508,7 @@ func TestSchemeHandler_APIPopupOpenInvokesPopupCallback(t *testing.T) { | |||
| 526 | 508 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |
| 527 | 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 }) | |
| 530 | - | require.NoError(t, err) | |
| 511 | + | h := newTestDumbSchemeHandler(t) | |
| 531 | 512 | ||
| 532 | 513 | const bridgeNonce = "bridge-nonce" | |
| 533 | 514 | browser := cefmocks.NewMockBrowser(t) | |
| @@ -570,8 +551,7 @@ func TestSchemeHandler_APIPopupNavigateInvokesPopupCallback(t *testing.T) { | |||
| 570 | 551 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |
| 571 | 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 }) | |
| 574 | - | require.NoError(t, err) | |
| 554 | + | h := newTestDumbSchemeHandler(t) | |
| 575 | 555 | ||
| 576 | 556 | const bridgeNonce = "bridge-nonce" | |
| 577 | 557 | browser := cefmocks.NewMockBrowser(t) | |
| @@ -611,8 +591,7 @@ func TestSchemeHandler_APIPopupCloseInvokesPopupCallback(t *testing.T) { | |||
| 611 | 591 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |
| 612 | 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 }) | |
| 615 | - | require.NoError(t, err) | |
| 594 | + | h := newTestDumbSchemeHandler(t) | |
| 616 | 595 | ||
| 617 | 596 | const bridgeNonce = "bridge-nonce" | |
| 618 | 597 | browser := cefmocks.NewMockBrowser(t) | |
| @@ -709,8 +688,7 @@ func TestSchemeHandler_APIPopupBridgeRejectsInvalidPayloads(t *testing.T) { | |||
| 709 | 688 | ||
| 710 | 689 | for _, test := range tests { | |
| 711 | 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 }) | |
| 713 | - | require.NoError(t, err) | |
| 691 | + | h := newTestDumbSchemeHandler(t) | |
| 714 | 692 | ||
| 715 | 693 | const bridgeNonce = "bridge-nonce" | |
| 716 | 694 | browser := cefmocks.NewMockBrowser(t) | |
| @@ -748,8 +726,7 @@ func TestSchemeHandler_APIOptionsReturnsCORSPreflightHeaders(t *testing.T) { | |||
| 748 | 726 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |
| 749 | 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 }) | |
| 752 | - | require.NoError(t, err) | |
| 729 | + | h := newTestDumbSchemeHandler(t) | |
| 753 | 730 | ||
| 754 | 731 | request := cefmocks.NewMockRequest(t) | |
| 755 | 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 | 9 | "github.com/bnema/dumber/internal/application/port" | |
| 10 | 10 | downloadutil "github.com/bnema/dumber/internal/domain/download" | |
| 11 | - | "github.com/bnema/dumber/internal/infrastructure/transcoder" | |
| 12 | 11 | "github.com/bnema/dumber/internal/logging" | |
| 13 | 12 | ) | |
| 14 | 13 | ||
| 15 | 14 | // Console message markers used by injected JavaScript for log filtering. | |
| 16 | 15 | const ( | |
| 17 | - | consoleMarkerVideoDiag = "[VIDEO-DIAG]" | |
| 18 | - | consoleMarkerRedditVideoPatch = "[REDDIT-VIDEO-PATCH]" | |
| 19 | - | consoleMarkerAutoCopy = "[AUTO-COPY]" | |
| 16 | + | consoleMarkerVideoDiag = "[VIDEO-DIAG]" | |
| 17 | + | consoleMarkerAutoCopy = "[AUTO-COPY]" | |
| 20 | 18 | ) | |
| 21 | 19 | ||
| 22 | 20 | // handlerSet implements all CEF handler interfaces and dispatches events to the | |
| 23 | 21 | // owning WebView. A single struct is used so that the Client's Get*Handler | |
| 24 | 22 | // methods can return the same receiver, avoiding extra allocations. | |
| 25 | 23 | type handlerSet struct { | |
| 26 | - | wv *WebView | |
| 27 | - | transcodingHandler purecef.ResourceRequestHandler | |
| 24 | + | wv *WebView | |
| 28 | 25 | } | |
| 29 | 26 | ||
| 30 | 27 | // Compile-time interface checks. | |
| @@ -495,7 +492,6 @@ func (h *handlerSet) OnConsoleMessage( | |||
| 495 | 492 | ) int32 { | |
| 496 | 493 | if h.wv != nil && h.wv.ctx != nil && | |
| 497 | 494 | (strings.Contains(message, consoleMarkerVideoDiag) || | |
| 498 | - | strings.Contains(message, consoleMarkerRedditVideoPatch) || | |
| 499 | 495 | strings.Contains(message, consoleMarkerAutoCopy)) { | |
| 500 | 496 | log := logging.FromContext(h.wv.ctx).With(). | |
| 501 | 497 | Str("component", "cef-console"). | |
| @@ -928,7 +924,7 @@ func (h *handlerSet) OnBeforeBrowse(browser purecef.Browser, frame purecef.Frame | |||
| 928 | 924 | } | |
| 929 | 925 | ||
| 930 | 926 | logging.FromContext(h.currentContext()).Debug(). | |
| 931 | - | Str("url", logging.TruncateURL(url, maxTranscodingURLLength)). | |
| 927 | + | Str("url", logging.TruncateURL(url, maxSchemeTruncatedURLLength)). | |
| 932 | 928 | Msg("cef: forcing download for navigation") | |
| 933 | 929 | ||
| 934 | 930 | host.StartDownload(url) | |
| @@ -993,21 +989,9 @@ func (h *handlerSet) OnOpenUrlfromTab( | |||
| 993 | 989 | } | |
| 994 | 990 | ||
| 995 | 991 | func (h *handlerSet) GetResourceRequestHandler( | |
| 996 | - | _ purecef.Browser, _ purecef.Frame, request purecef.Request, | |
| 997 | - | _, _ int32, _ string, disableDefaultHandling *int32, | |
| 992 | + | _ purecef.Browser, _ purecef.Frame, _ purecef.Request, | |
| 993 | + | _, _ int32, _ string, _ *int32, | |
| 998 | 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 | 995 | return nil | |
| 1012 | 996 | } | |
| 1013 | 997 | ||
M internal/infrastructure/config/defaults.go +0 -12
| @@ -71,12 +71,6 @@ const ( | |||
| 71 | 71 | defaultWebViewPoolPrewarmCount = 4 // WebViews to pre-create at startup | |
| 72 | 72 | 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 | 74 | // Skia threading defaults (0 = unset, -1 = unset for GPU threads) | |
| 81 | 75 | defaultSkiaCPUPaintingThreads = 0 | |
| 82 | 76 | defaultSkiaGPUPaintingThreads = -1 // -1 means unset; 0 would disable GPU tile painting | |
| @@ -324,12 +318,6 @@ func DefaultConfig() *Config { | |||
| 324 | 318 | Downloads: DownloadsConfig{ | |
| 325 | 319 | 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 | 13 | assert.Equal(t, defaultMaxLogFiles, cfg.Logging.MaxFiles) | |
| 14 | 14 | assert.False(t, cfg.Logging.CaptureGTKLogs) | |
| 15 | 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 | 17 | // Engine defaults (replaces old Performance/Privacy sections) | |
| 22 | 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 | 417 | m.setDebugDefaults(defaults) | |
| 418 | 418 | m.setAppearanceDefaults(defaults) | |
| 419 | 419 | m.setEngineDefaults(defaults) | |
| 420 | - | m.setTranscodingDefaults(defaults) | |
| 421 | 420 | m.setZoomAndScaleDefaults(defaults) | |
| 422 | 421 | m.setWorkspaceDefaults(defaults) | |
| 423 | 422 | m.setContentFilteringDefaults(defaults) | |
| @@ -611,13 +610,7 @@ func (m *Manager) setEngineDefaults(defaults *Config) { | |||
| 611 | 610 | m.viper.SetDefault("engine.webkit.network_process_memory_strict_threshold", wk.NetworkProcessMemoryStrictThreshold) | |
| 612 | 611 | } | |
| 613 | 612 | ||
| 614 | - | func (m *Manager) setTranscodingDefaults(defaults *Config) { | |
| 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 | - | } | |
| 613 | + | ||
| 621 | 614 | ||
| 622 | 615 | // New returns a new default configuration instance. | |
| 623 | 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 | 1 | package config | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "os" | |
| 5 | - | "path/filepath" | |
| 6 | 4 | "testing" | |
| 7 | 5 | ||
| 8 | 6 | "github.com/spf13/viper" | |
| 9 | 7 | "github.com/stretchr/testify/assert" | |
| 10 | - | "github.com/stretchr/testify/require" | |
| 11 | 8 | ) | |
| 12 | 9 | ||
| 13 | 10 | func TestSetEngineDefaults(t *testing.T) { | |
| @@ -27,12 +24,6 @@ func TestSetEngineDefaults(t *testing.T) { | |||
| 27 | 24 | assert.Equal(t, "auto", mgr.viper.GetString("engine.webkit.gl_rendering_mode")) | |
| 28 | 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 | 27 | // Old sections should NOT be registered as defaults | |
| 37 | 28 | assert.Empty(t, mgr.viper.GetString("privacy.cookie_policy")) | |
| 38 | 29 | assert.Empty(t, mgr.viper.GetString("rendering.mode")) | |
| @@ -49,33 +40,4 @@ func TestNormalizeConfig_EngineCookiePolicy(t *testing.T) { | |||
| 49 | 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 | 110 | Downloads DownloadsConfig `mapstructure:"downloads" yaml:"downloads" toml:"downloads"` | |
| 111 | 111 | // Engine holds engine selection and unified engine options. | |
| 112 | 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 | 115 | // CookiePolicy controls cookie acceptance behavior. | |
| @@ -384,19 +382,4 @@ type DownloadsConfig struct { | |||
| 384 | 382 | Path string `mapstructure:"path" yaml:"path" toml:"path"` | |
| 385 | 383 | } | |
| 386 | 384 | ||
| 387 | - | // TranscodingConfig controls GPU-accelerated media transcoding. | |
| 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 | - | } | |
| 385 | + | ||
M internal/infrastructure/config/schema_provider.go +6 -5
| @@ -1025,11 +1025,12 @@ func (*SchemaProvider) getPerformanceKeys(defaults *Config) []entity.ConfigKeyIn | |||
| 1025 | 1025 | func (*SchemaProvider) getRuntimeKeys(defaults *Config) []entity.ConfigKeyInfo { | |
| 1026 | 1026 | return []entity.ConfigKeyInfo{ | |
| 1027 | 1027 | { | |
| 1028 | - | Key: "engine.cef.cef_dir", | |
| 1029 | - | Type: "string", | |
| 1030 | - | Default: defaults.Engine.CEF.CEFDir, | |
| 1031 | - | Description: "Custom CEF runtime directory containing libcef and Resources", | |
| 1032 | - | Section: SectionRuntime, | |
| 1028 | + | Key: "engine.cef.cef_dir", | |
| 1029 | + | Type: "string", | |
| 1030 | + | Default: defaults.Engine.CEF.CEFDir, | |
| 1031 | + | Description: "Optional CEF runtime directory containing libcef.so and Resources; overrides the default " + | |
| 1032 | + | "runtime discovery order (CEF_DIR env, /usr/lib/cef, ~/.local/share/cef)", | |
| 1033 | + | Section: SectionRuntime, | |
| 1033 | 1034 | }, | |
| 1034 | 1035 | { | |
| 1035 | 1036 | Key: "engine.webkit.prefix", | |
M internal/infrastructure/config/writer_test.go +0 -22
| @@ -1,7 +1,6 @@ | |||
| 1 | 1 | package config | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "bytes" | |
| 5 | 4 | "os" | |
| 6 | 5 | "path/filepath" | |
| 7 | 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 | 46 | t.Logf("Found %d sections, all sorted", len(sections)) | |
| 69 | 47 | ||
| 70 | 48 | // 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() {} | |