feat: GPU transcoding pipeline improvements + Reddit video patch + logging
Transcoding pipeline: - Pass TranscodingConfig explicitly to NewEngine (not via global config.Get()) - Transcoder startup state tracking with log-once diagnostic on SetHandlerContext - Transcode API endpoint (/api/transcode) with URL validation and header forwarding - transcodingResourceHandler: streaming WebM via ffmpeg with proper lifecycle - Reddit-specific JS patch: replaces blob/MSE playback with direct HLS→transcode when MP4 MSE codecs are unsupported in CEF OSR - Video diagnostic JS for debugging playback issues (temporary) - Audio stream detection and remuxing improvements - ffmpeg offset detection for codec capability probing - Pipeline improvements: better error handling, context cancellation, seek support Config: - Transcoding config defaults registered in loader - Schema fix and new loader/writer tests Logging: - TruncateURL helper for safe URL logging - Logger test coverage CEF engine: - Fix session cleanup ordering in main.go (End before LogCleanup) - Factory passes engine context to transcoding handler - webview_handlers: video diagnostic injection on OnLoadEnd
- commit
- aeff368b87abb68650988d7300eafca8f86287fa
- author
- brice · · 2026-03-25 05:33 UTC
- committer
- brice · · 2026-03-28 04:39 UTC
- parent
- b39db92
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
23 files · +1389 -217
M cmd/dumber/main.go +1 -1
| @@ -200,10 +200,10 @@ func initBrowserSession( | |||
| 200 | 200 | bootstrapLog.Fatal().Err(err).Msg("failed to start session") | |
| 201 | 201 | } | |
| 202 | 202 | cleanup := func() { | |
| 203 | + | _ = browserSession.End(sessionCtx) | |
| 203 | 204 | if browserSession.LogCleanup != nil { | |
| 204 | 205 | browserSession.LogCleanup() | |
| 205 | 206 | } | |
| 206 | - | _ = browserSession.End(sessionCtx) | |
| 207 | 207 | } | |
| 208 | 208 | return sessionCtx, browserSession, cleanup | |
| 209 | 209 | } | |
| @@ -200,10 +200,10 @@ func initBrowserSession( | |||||
| 200 | bootstrapLog.Fatal().Err(err).Msg("failed to start session") | 200 | bootstrapLog.Fatal().Err(err).Msg("failed to start session") | ||
| 201 | } | 201 | } | ||
| 202 | cleanup := func() { | 202 | cleanup := func() { | ||
| 203 | + | _ = browserSession.End(sessionCtx) | |||
| 203 | if browserSession.LogCleanup != nil { | 204 | if browserSession.LogCleanup != nil { | ||
| 204 | browserSession.LogCleanup() | 205 | browserSession.LogCleanup() | ||
| 205 | } | 206 | } | ||
| 206 | - | _ = browserSession.End(sessionCtx) | |||
| 207 | } | 207 | } | ||
| 208 | return sessionCtx, browserSession, cleanup | 208 | return sessionCtx, browserSession, cleanup | ||
| 209 | } | 209 | } | ||
M internal/bootstrap/engine.go +1 -1
| @@ -42,7 +42,7 @@ func BuildEngine(input EngineInput) (port.Engine, error) { | |||
| 42 | 42 | ) | |
| 43 | 43 | case "cef": | |
| 44 | 44 | cefCfg := cfg.Engine.CEF | |
| 45 | - | return cef.NewEngine(input.Ctx, cefCfg) | |
| 45 | + | return cef.NewEngine(input.Ctx, cefCfg, cfg.Transcoding) | |
| 46 | 46 | default: | |
| 47 | 47 | return nil, fmt.Errorf("unknown engine type: %q", engineType) | |
| 48 | 48 | } | |
| @@ -42,7 +42,7 @@ func BuildEngine(input EngineInput) (port.Engine, error) { | |||||
| 42 | ) | 42 | ) | ||
| 43 | case "cef": | 43 | case "cef": | ||
| 44 | cefCfg := cfg.Engine.CEF | 44 | cefCfg := cfg.Engine.CEF | ||
| 45 | - | return cef.NewEngine(input.Ctx, cefCfg) | 45 | + | return cef.NewEngine(input.Ctx, cefCfg, cfg.Transcoding) |
| 46 | default: | 46 | default: | ||
| 47 | return nil, fmt.Errorf("unknown engine type: %q", engineType) | 47 | return nil, fmt.Errorf("unknown engine type: %q", engineType) | ||
| 48 | } | 48 | } | ||
M internal/infrastructure/cef/content_injector.go +214 -0
| @@ -156,6 +156,216 @@ const videoDiagnosticJS = `(function(){ | |||
| 156 | 156 | 'aac:', MediaSource ? MediaSource.isTypeSupported('audio/mp4; codecs="mp4a.40.2"') : 'n/a'); | |
| 157 | 157 | })();` | |
| 158 | 158 | ||
| 159 | + | // redditDirectVideoJS replaces Reddit's blob/MSE playback path with a direct | |
| 160 | + | // HLS source when MP4 MSE buffers are unsupported. This lets the browser issue | |
| 161 | + | // a normal media request that our transcoding handler can answer with WebM. | |
| 162 | + | const redditDirectVideoJS = `(function(){ | |
| 163 | + | var tag = '[REDDIT-VIDEO-PATCH]'; | |
| 164 | + | if (!/(\.|^)reddit\.com$/i.test(location.hostname)) return; | |
| 165 | + | if (!window.MediaSource) return; | |
| 166 | + | if (MediaSource.isTypeSupported('video/mp4; codecs="avc1.4d401e"') && | |
| 167 | + | MediaSource.isTypeSupported('audio/mp4; codecs="mp4a.40.2"')) { | |
| 168 | + | return; | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | console.warn(tag, 'active for unsupported Reddit MSE playback'); | |
| 172 | + | ||
| 173 | + | function ensureStyle() { | |
| 174 | + | if (document.getElementById('dumber-reddit-direct-video-style')) return; | |
| 175 | + | var style = document.createElement('style'); | |
| 176 | + | style.id = 'dumber-reddit-direct-video-style'; | |
| 177 | + | style.textContent = | |
| 178 | + | 'shreddit-player[data-dumber-direct-video="1"] { position: relative !important; }' + | |
| 179 | + | 'shreddit-player[data-dumber-direct-video="1"] > .dumber-direct-video {' + | |
| 180 | + | ' position: absolute !important; inset: 0 !important; width: 100% !important; height: 100% !important;' + | |
| 181 | + | ' object-fit: contain !important; background: #000 !important; z-index: 2147483647 !important;' + | |
| 182 | + | ' display: block !important; }'; | |
| 183 | + | document.head.appendChild(style); | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | function candidateRoots(video) { | |
| 187 | + | var roots = []; | |
| 188 | + | var seen = []; | |
| 189 | + | function add(root) { | |
| 190 | + | if (!root) return; | |
| 191 | + | if (seen.indexOf(root) !== -1) return; | |
| 192 | + | seen.push(root); | |
| 193 | + | roots.push(root); | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | var current = video; | |
| 197 | + | while (current) { | |
| 198 | + | add(current); | |
| 199 | + | if (current.shadowRoot) add(current.shadowRoot); | |
| 200 | + | current = current.parentElement; | |
| 201 | + | } | |
| 202 | + | add(document); | |
| 203 | + | return roots; | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | function findHlsSource(video) { | |
| 207 | + | var selectors = [ | |
| 208 | + | 'source[type="application/vnd.apple.mpegURL"][src]', | |
| 209 | + | 'source[type="application/x-mpegURL"][src]', | |
| 210 | + | 'source[src*="HLSPlaylist.m3u8"]', | |
| 211 | + | '[src*="HLSPlaylist.m3u8"]' | |
| 212 | + | ]; | |
| 213 | + | ||
| 214 | + | var roots = candidateRoots(video); | |
| 215 | + | for (var i = 0; i < roots.length; i++) { | |
| 216 | + | var root = roots[i]; | |
| 217 | + | if (!root.querySelector) continue; | |
| 218 | + | for (var j = 0; j < selectors.length; j++) { | |
| 219 | + | var el = root.querySelector(selectors[j]); | |
| 220 | + | if (el && el.src) return el.src; | |
| 221 | + | } | |
| 222 | + | if (root.querySelectorAll) { | |
| 223 | + | var all = root.querySelectorAll('*'); | |
| 224 | + | for (var k = 0; k < all.length; k++) { | |
| 225 | + | if (all[k].shadowRoot) { | |
| 226 | + | roots.push(all[k].shadowRoot); | |
| 227 | + | } | |
| 228 | + | } | |
| 229 | + | } | |
| 230 | + | } | |
| 231 | + | return ''; | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | function buildPlaybackURL(hlsURL) { | |
| 235 | + | return location.origin + '/__dumber__/transcode.webm?src=' + encodeURIComponent(hlsURL) + | |
| 236 | + | '&referer=' + encodeURIComponent(location.href) + | |
| 237 | + | '&origin=' + encodeURIComponent(location.origin); | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | function getPlayerRoot(video) { | |
| 241 | + | return video.closest('shreddit-player') || video.parentElement; | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | function monitorReplacement(video) { | |
| 245 | + | if (!video || video._dumberReplacementDiag) return; | |
| 246 | + | video._dumberReplacementDiag = true; | |
| 247 | + | ['loadstart','loadedmetadata','loadeddata','canplay','playing','waiting','stalled','suspend','abort','emptied','error'].forEach(function(evt) { | |
| 248 | + | video.addEventListener(evt, function() { | |
| 249 | + | var err = video.error ? ('code=' + video.error.code + ' msg=' + video.error.message) : 'none'; | |
| 250 | + | console.warn(tag, 'replacement', evt, 'ready:', video.readyState, 'net:', video.networkState, | |
| 251 | + | 'paused:', video.paused, 'error:', err, 'src:', (video.currentSrc || video.src || '').substring(0, 120)); | |
| 252 | + | }); | |
| 253 | + | }); | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | function ensureReplacement(video, playbackURL) { | |
| 257 | + | var root = getPlayerRoot(video); | |
| 258 | + | if (!root) return null; | |
| 259 | + | ensureStyle(); | |
| 260 | + | ||
| 261 | + | var replacement = root.querySelector(':scope > .dumber-direct-video'); | |
| 262 | + | if (!replacement) { | |
| 263 | + | replacement = document.createElement('video'); | |
| 264 | + | replacement.className = 'dumber-direct-video'; | |
| 265 | + | replacement.controls = true; | |
| 266 | + | replacement.playsInline = true; | |
| 267 | + | replacement.preload = 'auto'; | |
| 268 | + | replacement.autoplay = true; | |
| 269 | + | replacement.muted = true; | |
| 270 | + | replacement.loop = video.loop; | |
| 271 | + | root.setAttribute('data-dumber-direct-video', '1'); | |
| 272 | + | root.appendChild(replacement); | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | replacement.poster = video.poster || replacement.poster || ''; | |
| 276 | + | monitorReplacement(replacement); | |
| 277 | + | if (replacement.src !== playbackURL) { | |
| 278 | + | replacement.src = playbackURL; | |
| 279 | + | replacement.load(); | |
| 280 | + | } | |
| 281 | + | return replacement; | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | function shouldForce(video) { | |
| 285 | + | var current = video.currentSrc || video.src || ''; | |
| 286 | + | return current.indexOf('blob:') === 0; | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | function forceDirectPlayback(video) { | |
| 290 | + | if (!video || !shouldForce(video)) return; | |
| 291 | + | ||
| 292 | + | var hlsURL = findHlsSource(video); | |
| 293 | + | if (!hlsURL) { | |
| 294 | + | console.warn(tag, 'blob video found without HLS source'); | |
| 295 | + | return; | |
| 296 | + | } | |
| 297 | + | var playbackURL = buildPlaybackURL(hlsURL); | |
| 298 | + | if (video._dumberPlaybackURL === playbackURL) return; | |
| 299 | + | video._dumberPlaybackURL = playbackURL; | |
| 300 | + | ||
| 301 | + | console.warn(tag, 'forcing replacement video source:', playbackURL.substring(0, 120), 'from:', hlsURL.substring(0, 120)); | |
| 302 | + | ||
| 303 | + | var replacement = ensureReplacement(video, playbackURL); | |
| 304 | + | if (!replacement) return; | |
| 305 | + | ||
| 306 | + | var root = getPlayerRoot(video); | |
| 307 | + | if (root && root.querySelectorAll) { | |
| 308 | + | root.querySelectorAll('video').forEach(function(candidate) { | |
| 309 | + | if (candidate === replacement) return; | |
| 310 | + | try { candidate.pause(); } catch (e) {} | |
| 311 | + | try { candidate.removeAttribute('src'); } catch (e) {} | |
| 312 | + | try { candidate.srcObject = null; } catch (e) {} | |
| 313 | + | try { candidate.load(); } catch (e) {} | |
| 314 | + | candidate.style.display = 'none'; | |
| 315 | + | }); | |
| 316 | + | } else { | |
| 317 | + | try { video.pause(); } catch (e) {} | |
| 318 | + | try { video.removeAttribute('src'); } catch (e) {} | |
| 319 | + | try { video.srcObject = null; } catch (e) {} | |
| 320 | + | try { video.load(); } catch (e) {} | |
| 321 | + | video.style.display = 'none'; | |
| 322 | + | } | |
| 323 | + | ||
| 324 | + | var playPromise = replacement.play(); | |
| 325 | + | if (playPromise && typeof playPromise.catch === 'function') { | |
| 326 | + | playPromise.catch(function(err) { | |
| 327 | + | console.warn(tag, 'replacement video.play() rejected:', err && err.message ? err.message : err); | |
| 328 | + | }); | |
| 329 | + | } | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | function patchTree(root) { | |
| 333 | + | if (!root || !root.querySelectorAll) return; | |
| 334 | + | root.querySelectorAll('video').forEach(forceDirectPlayback); | |
| 335 | + | root.querySelectorAll('*').forEach(function(el) { | |
| 336 | + | if (el.shadowRoot) patchTree(el.shadowRoot); | |
| 337 | + | }); | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | new MutationObserver(function(mutations) { | |
| 341 | + | mutations.forEach(function(mutation) { | |
| 342 | + | if (mutation.type === 'attributes' && mutation.target && mutation.target.nodeName === 'VIDEO') { | |
| 343 | + | forceDirectPlayback(mutation.target); | |
| 344 | + | } | |
| 345 | + | mutation.addedNodes.forEach(function(node) { | |
| 346 | + | if (node.nodeName === 'VIDEO') forceDirectPlayback(node); | |
| 347 | + | patchTree(node); | |
| 348 | + | if (node.shadowRoot) patchTree(node.shadowRoot); | |
| 349 | + | }); | |
| 350 | + | }); | |
| 351 | + | }).observe(document.documentElement, { | |
| 352 | + | subtree: true, | |
| 353 | + | childList: true, | |
| 354 | + | attributes: true, | |
| 355 | + | attributeFilter: ['src'] | |
| 356 | + | }); | |
| 357 | + | ||
| 358 | + | var origAttach = Element.prototype.attachShadow; | |
| 359 | + | Element.prototype.attachShadow = function(opts) { | |
| 360 | + | var sr = origAttach.call(this, opts); | |
| 361 | + | patchTree(sr); | |
| 362 | + | return sr; | |
| 363 | + | }; | |
| 364 | + | ||
| 365 | + | patchTree(document); | |
| 366 | + | setInterval(function() { patchTree(document); }, 1000); | |
| 367 | + | })();` | |
| 368 | + | ||
| 159 | 369 | // contentInjector implements port.ContentInjector for the CEF engine. | |
| 160 | 370 | // It stores CSS strings and injects them into webviews via ExecuteJavaScript. | |
| 161 | 371 | // Thread-safe: InjectThemeCSS may be called from the UI thread while OnLoadEnd | |
| @@ -275,6 +485,10 @@ func (ci *contentInjector) onLoadEnd(wv *WebView) { | |||
| 275 | 485 | // Video playback diagnostic — logs video element state changes. | |
| 276 | 486 | // TODO: remove once video playback is stable. | |
| 277 | 487 | wv.RunJavaScript(context.Background(), videoDiagnosticJS) | |
| 488 | + | ||
| 489 | + | if strings.Contains(uri, "reddit.com") { | |
| 490 | + | wv.RunJavaScript(context.Background(), redditDirectVideoJS) | |
| 491 | + | } | |
| 278 | 492 | } | |
| 279 | 493 | ||
| 280 | 494 | // injectCSS injects a CSS string as a <style> element via JavaScript. | |
| @@ -156,6 +156,216 @@ const videoDiagnosticJS = `(function(){ | |||||
| 156 | 'aac:', MediaSource ? MediaSource.isTypeSupported('audio/mp4; codecs="mp4a.40.2"') : 'n/a'); | 156 | 'aac:', MediaSource ? MediaSource.isTypeSupported('audio/mp4; codecs="mp4a.40.2"') : 'n/a'); | ||
| 157 | })();` | 157 | })();` | ||
| 158 | 158 | ||||
| 159 | + | // redditDirectVideoJS replaces Reddit's blob/MSE playback path with a direct | |||
| 160 | + | // HLS source when MP4 MSE buffers are unsupported. This lets the browser issue | |||
| 161 | + | // a normal media request that our transcoding handler can answer with WebM. | |||
| 162 | + | const redditDirectVideoJS = `(function(){ | |||
| 163 | + | var tag = '[REDDIT-VIDEO-PATCH]'; | |||
| 164 | + | if (!/(\.|^)reddit\.com$/i.test(location.hostname)) return; | |||
| 165 | + | if (!window.MediaSource) return; | |||
| 166 | + | if (MediaSource.isTypeSupported('video/mp4; codecs="avc1.4d401e"') && | |||
| 167 | + | MediaSource.isTypeSupported('audio/mp4; codecs="mp4a.40.2"')) { | |||
| 168 | + | return; | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | console.warn(tag, 'active for unsupported Reddit MSE playback'); | |||
| 172 | + | ||||
| 173 | + | function ensureStyle() { | |||
| 174 | + | if (document.getElementById('dumber-reddit-direct-video-style')) return; | |||
| 175 | + | var style = document.createElement('style'); | |||
| 176 | + | style.id = 'dumber-reddit-direct-video-style'; | |||
| 177 | + | style.textContent = | |||
| 178 | + | 'shreddit-player[data-dumber-direct-video="1"] { position: relative !important; }' + | |||
| 179 | + | 'shreddit-player[data-dumber-direct-video="1"] > .dumber-direct-video {' + | |||
| 180 | + | ' position: absolute !important; inset: 0 !important; width: 100% !important; height: 100% !important;' + | |||
| 181 | + | ' object-fit: contain !important; background: #000 !important; z-index: 2147483647 !important;' + | |||
| 182 | + | ' display: block !important; }'; | |||
| 183 | + | document.head.appendChild(style); | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | function candidateRoots(video) { | |||
| 187 | + | var roots = []; | |||
| 188 | + | var seen = []; | |||
| 189 | + | function add(root) { | |||
| 190 | + | if (!root) return; | |||
| 191 | + | if (seen.indexOf(root) !== -1) return; | |||
| 192 | + | seen.push(root); | |||
| 193 | + | roots.push(root); | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | var current = video; | |||
| 197 | + | while (current) { | |||
| 198 | + | add(current); | |||
| 199 | + | if (current.shadowRoot) add(current.shadowRoot); | |||
| 200 | + | current = current.parentElement; | |||
| 201 | + | } | |||
| 202 | + | add(document); | |||
| 203 | + | return roots; | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | function findHlsSource(video) { | |||
| 207 | + | var selectors = [ | |||
| 208 | + | 'source[type="application/vnd.apple.mpegURL"][src]', | |||
| 209 | + | 'source[type="application/x-mpegURL"][src]', | |||
| 210 | + | 'source[src*="HLSPlaylist.m3u8"]', | |||
| 211 | + | '[src*="HLSPlaylist.m3u8"]' | |||
| 212 | + | ]; | |||
| 213 | + | ||||
| 214 | + | var roots = candidateRoots(video); | |||
| 215 | + | for (var i = 0; i < roots.length; i++) { | |||
| 216 | + | var root = roots[i]; | |||
| 217 | + | if (!root.querySelector) continue; | |||
| 218 | + | for (var j = 0; j < selectors.length; j++) { | |||
| 219 | + | var el = root.querySelector(selectors[j]); | |||
| 220 | + | if (el && el.src) return el.src; | |||
| 221 | + | } | |||
| 222 | + | if (root.querySelectorAll) { | |||
| 223 | + | var all = root.querySelectorAll('*'); | |||
| 224 | + | for (var k = 0; k < all.length; k++) { | |||
| 225 | + | if (all[k].shadowRoot) { | |||
| 226 | + | roots.push(all[k].shadowRoot); | |||
| 227 | + | } | |||
| 228 | + | } | |||
| 229 | + | } | |||
| 230 | + | } | |||
| 231 | + | return ''; | |||
| 232 | + | } | |||
| 233 | + | ||||
| 234 | + | function buildPlaybackURL(hlsURL) { | |||
| 235 | + | return location.origin + '/__dumber__/transcode.webm?src=' + encodeURIComponent(hlsURL) + | |||
| 236 | + | '&referer=' + encodeURIComponent(location.href) + | |||
| 237 | + | '&origin=' + encodeURIComponent(location.origin); | |||
| 238 | + | } | |||
| 239 | + | ||||
| 240 | + | function getPlayerRoot(video) { | |||
| 241 | + | return video.closest('shreddit-player') || video.parentElement; | |||
| 242 | + | } | |||
| 243 | + | ||||
| 244 | + | function monitorReplacement(video) { | |||
| 245 | + | if (!video || video._dumberReplacementDiag) return; | |||
| 246 | + | video._dumberReplacementDiag = true; | |||
| 247 | + | ['loadstart','loadedmetadata','loadeddata','canplay','playing','waiting','stalled','suspend','abort','emptied','error'].forEach(function(evt) { | |||
| 248 | + | video.addEventListener(evt, function() { | |||
| 249 | + | var err = video.error ? ('code=' + video.error.code + ' msg=' + video.error.message) : 'none'; | |||
| 250 | + | console.warn(tag, 'replacement', evt, 'ready:', video.readyState, 'net:', video.networkState, | |||
| 251 | + | 'paused:', video.paused, 'error:', err, 'src:', (video.currentSrc || video.src || '').substring(0, 120)); | |||
| 252 | + | }); | |||
| 253 | + | }); | |||
| 254 | + | } | |||
| 255 | + | ||||
| 256 | + | function ensureReplacement(video, playbackURL) { | |||
| 257 | + | var root = getPlayerRoot(video); | |||
| 258 | + | if (!root) return null; | |||
| 259 | + | ensureStyle(); | |||
| 260 | + | ||||
| 261 | + | var replacement = root.querySelector(':scope > .dumber-direct-video'); | |||
| 262 | + | if (!replacement) { | |||
| 263 | + | replacement = document.createElement('video'); | |||
| 264 | + | replacement.className = 'dumber-direct-video'; | |||
| 265 | + | replacement.controls = true; | |||
| 266 | + | replacement.playsInline = true; | |||
| 267 | + | replacement.preload = 'auto'; | |||
| 268 | + | replacement.autoplay = true; | |||
| 269 | + | replacement.muted = true; | |||
| 270 | + | replacement.loop = video.loop; | |||
| 271 | + | root.setAttribute('data-dumber-direct-video', '1'); | |||
| 272 | + | root.appendChild(replacement); | |||
| 273 | + | } | |||
| 274 | + | ||||
| 275 | + | replacement.poster = video.poster || replacement.poster || ''; | |||
| 276 | + | monitorReplacement(replacement); | |||
| 277 | + | if (replacement.src !== playbackURL) { | |||
| 278 | + | replacement.src = playbackURL; | |||
| 279 | + | replacement.load(); | |||
| 280 | + | } | |||
| 281 | + | return replacement; | |||
| 282 | + | } | |||
| 283 | + | ||||
| 284 | + | function shouldForce(video) { | |||
| 285 | + | var current = video.currentSrc || video.src || ''; | |||
| 286 | + | return current.indexOf('blob:') === 0; | |||
| 287 | + | } | |||
| 288 | + | ||||
| 289 | + | function forceDirectPlayback(video) { | |||
| 290 | + | if (!video || !shouldForce(video)) return; | |||
| 291 | + | ||||
| 292 | + | var hlsURL = findHlsSource(video); | |||
| 293 | + | if (!hlsURL) { | |||
| 294 | + | console.warn(tag, 'blob video found without HLS source'); | |||
| 295 | + | return; | |||
| 296 | + | } | |||
| 297 | + | var playbackURL = buildPlaybackURL(hlsURL); | |||
| 298 | + | if (video._dumberPlaybackURL === playbackURL) return; | |||
| 299 | + | video._dumberPlaybackURL = playbackURL; | |||
| 300 | + | ||||
| 301 | + | console.warn(tag, 'forcing replacement video source:', playbackURL.substring(0, 120), 'from:', hlsURL.substring(0, 120)); | |||
| 302 | + | ||||
| 303 | + | var replacement = ensureReplacement(video, playbackURL); | |||
| 304 | + | if (!replacement) return; | |||
| 305 | + | ||||
| 306 | + | var root = getPlayerRoot(video); | |||
| 307 | + | if (root && root.querySelectorAll) { | |||
| 308 | + | root.querySelectorAll('video').forEach(function(candidate) { | |||
| 309 | + | if (candidate === replacement) return; | |||
| 310 | + | try { candidate.pause(); } catch (e) {} | |||
| 311 | + | try { candidate.removeAttribute('src'); } catch (e) {} | |||
| 312 | + | try { candidate.srcObject = null; } catch (e) {} | |||
| 313 | + | try { candidate.load(); } catch (e) {} | |||
| 314 | + | candidate.style.display = 'none'; | |||
| 315 | + | }); | |||
| 316 | + | } else { | |||
| 317 | + | try { video.pause(); } catch (e) {} | |||
| 318 | + | try { video.removeAttribute('src'); } catch (e) {} | |||
| 319 | + | try { video.srcObject = null; } catch (e) {} | |||
| 320 | + | try { video.load(); } catch (e) {} | |||
| 321 | + | video.style.display = 'none'; | |||
| 322 | + | } | |||
| 323 | + | ||||
| 324 | + | var playPromise = replacement.play(); | |||
| 325 | + | if (playPromise && typeof playPromise.catch === 'function') { | |||
| 326 | + | playPromise.catch(function(err) { | |||
| 327 | + | console.warn(tag, 'replacement video.play() rejected:', err && err.message ? err.message : err); | |||
| 328 | + | }); | |||
| 329 | + | } | |||
| 330 | + | } | |||
| 331 | + | ||||
| 332 | + | function patchTree(root) { | |||
| 333 | + | if (!root || !root.querySelectorAll) return; | |||
| 334 | + | root.querySelectorAll('video').forEach(forceDirectPlayback); | |||
| 335 | + | root.querySelectorAll('*').forEach(function(el) { | |||
| 336 | + | if (el.shadowRoot) patchTree(el.shadowRoot); | |||
| 337 | + | }); | |||
| 338 | + | } | |||
| 339 | + | ||||
| 340 | + | new MutationObserver(function(mutations) { | |||
| 341 | + | mutations.forEach(function(mutation) { | |||
| 342 | + | if (mutation.type === 'attributes' && mutation.target && mutation.target.nodeName === 'VIDEO') { | |||
| 343 | + | forceDirectPlayback(mutation.target); | |||
| 344 | + | } | |||
| 345 | + | mutation.addedNodes.forEach(function(node) { | |||
| 346 | + | if (node.nodeName === 'VIDEO') forceDirectPlayback(node); | |||
| 347 | + | patchTree(node); | |||
| 348 | + | if (node.shadowRoot) patchTree(node.shadowRoot); | |||
| 349 | + | }); | |||
| 350 | + | }); | |||
| 351 | + | }).observe(document.documentElement, { | |||
| 352 | + | subtree: true, | |||
| 353 | + | childList: true, | |||
| 354 | + | attributes: true, | |||
| 355 | + | attributeFilter: ['src'] | |||
| 356 | + | }); | |||
| 357 | + | ||||
| 358 | + | var origAttach = Element.prototype.attachShadow; | |||
| 359 | + | Element.prototype.attachShadow = function(opts) { | |||
| 360 | + | var sr = origAttach.call(this, opts); | |||
| 361 | + | patchTree(sr); | |||
| 362 | + | return sr; | |||
| 363 | + | }; | |||
| 364 | + | ||||
| 365 | + | patchTree(document); | |||
| 366 | + | setInterval(function() { patchTree(document); }, 1000); | |||
| 367 | + | })();` | |||
| 368 | + | ||||
| 159 | // contentInjector implements port.ContentInjector for the CEF engine. | 369 | // contentInjector implements port.ContentInjector for the CEF engine. | ||
| 160 | // It stores CSS strings and injects them into webviews via ExecuteJavaScript. | 370 | // It stores CSS strings and injects them into webviews via ExecuteJavaScript. | ||
| 161 | // Thread-safe: InjectThemeCSS may be called from the UI thread while OnLoadEnd | 371 | // Thread-safe: InjectThemeCSS may be called from the UI thread while OnLoadEnd | ||
| @@ -275,6 +485,10 @@ func (ci *contentInjector) onLoadEnd(wv *WebView) { | |||||
| 275 | // Video playback diagnostic — logs video element state changes. | 485 | // Video playback diagnostic — logs video element state changes. | ||
| 276 | // TODO: remove once video playback is stable. | 486 | // TODO: remove once video playback is stable. | ||
| 277 | wv.RunJavaScript(context.Background(), videoDiagnosticJS) | 487 | wv.RunJavaScript(context.Background(), videoDiagnosticJS) | ||
| 488 | + | ||||
| 489 | + | if strings.Contains(uri, "reddit.com") { | |||
| 490 | + | wv.RunJavaScript(context.Background(), redditDirectVideoJS) | |||
| 491 | + | } | |||
| 278 | } | 492 | } | ||
| 279 | 493 | ||||
| 280 | // injectCSS injects a CSS string as a <style> element via JavaScript. | 494 | // injectCSS injects a CSS string as a <style> element via JavaScript. | ||
M internal/infrastructure/cef/engine.go +53 -0
| @@ -58,6 +58,21 @@ type Engine struct { | |||
| 58 | 58 | browserCreateLastWidth atomic.Int32 | |
| 59 | 59 | browserCreateLastHeight atomic.Int32 | |
| 60 | 60 | lastStallLoggedCreateSeq atomic.Uint64 | |
| 61 | + | ||
| 62 | + | transcoderState transcoderStartupState | |
| 63 | + | transcoderStateLogged atomic.Bool | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | type transcoderStartupState struct { | |
| 67 | + | ConfigEnabled bool | |
| 68 | + | ProbeAttempted bool | |
| 69 | + | HWAccel string | |
| 70 | + | MaxConcurrent int | |
| 71 | + | Quality string | |
| 72 | + | Status string | |
| 73 | + | API string | |
| 74 | + | Encoders []string | |
| 75 | + | Decoders []string | |
| 61 | 76 | } | |
| 62 | 77 | ||
| 63 | 78 | // Factory returns the WebViewFactory for creating new WebView instances. | |
| @@ -299,6 +314,7 @@ func (e *Engine) SetHandlerContext(ctx context.Context) { | |||
| 299 | 314 | if e.messageRouter != nil { | |
| 300 | 315 | e.messageRouter.SetBaseContext(ctx) | |
| 301 | 316 | } | |
| 317 | + | e.logTranscoderStartupState() | |
| 302 | 318 | } | |
| 303 | 319 | ||
| 304 | 320 | const browserCreateStallWarnMS int64 = 1500 | |
| @@ -400,6 +416,43 @@ func (e *Engine) maybeLogBrowserCreateStall() { | |||
| 400 | 416 | Msg("cef: browser creation appears stalled") | |
| 401 | 417 | } | |
| 402 | 418 | ||
| 419 | + | func (e *Engine) currentContext() context.Context { | |
| 420 | + | if e == nil || e.ctx == nil { | |
| 421 | + | return context.Background() | |
| 422 | + | } | |
| 423 | + | return e.ctx | |
| 424 | + | } | |
| 425 | + | ||
| 426 | + | func (e *Engine) logTranscoderStartupState() { | |
| 427 | + | if e == nil || !e.transcoderStateLogged.CompareAndSwap(false, true) { | |
| 428 | + | return | |
| 429 | + | } | |
| 430 | + | ||
| 431 | + | state := e.transcoderState | |
| 432 | + | log := logging.FromContext(e.currentContext()) | |
| 433 | + | event := log.Info(). | |
| 434 | + | Str("component", "cef-transcoder"). | |
| 435 | + | Bool("config_enabled", state.ConfigEnabled). | |
| 436 | + | Bool("probe_attempted", state.ProbeAttempted). | |
| 437 | + | Str("hwaccel", state.HWAccel). | |
| 438 | + | Int("max_concurrent", state.MaxConcurrent). | |
| 439 | + | Str("quality", state.Quality). | |
| 440 | + | Str("status", state.Status). | |
| 441 | + | Bool("request_handler_enabled", e.factory != nil && e.factory.transcoder != nil) | |
| 442 | + | ||
| 443 | + | if state.API != "" { | |
| 444 | + | event = event.Str("api", state.API) | |
| 445 | + | } | |
| 446 | + | if len(state.Encoders) > 0 { | |
| 447 | + | event = event.Strs("encoders", state.Encoders) | |
| 448 | + | } | |
| 449 | + | if len(state.Decoders) > 0 { | |
| 450 | + | event = event.Strs("decoders", state.Decoders) | |
| 451 | + | } | |
| 452 | + | ||
| 453 | + | event.Msg("cef: transcoder startup state") | |
| 454 | + | } | |
| 455 | + | ||
| 403 | 456 | // --------------------------------------------------------------------------- | |
| 404 | 457 | // CEF message pump | |
| 405 | 458 | // --------------------------------------------------------------------------- | |
| @@ -58,6 +58,21 @@ type Engine struct { | |||||
| 58 | browserCreateLastWidth atomic.Int32 | 58 | browserCreateLastWidth atomic.Int32 | ||
| 59 | browserCreateLastHeight atomic.Int32 | 59 | browserCreateLastHeight atomic.Int32 | ||
| 60 | lastStallLoggedCreateSeq atomic.Uint64 | 60 | lastStallLoggedCreateSeq atomic.Uint64 | ||
| 61 | + | ||||
| 62 | + | transcoderState transcoderStartupState | |||
| 63 | + | transcoderStateLogged atomic.Bool | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | type transcoderStartupState struct { | |||
| 67 | + | ConfigEnabled bool | |||
| 68 | + | ProbeAttempted bool | |||
| 69 | + | HWAccel string | |||
| 70 | + | MaxConcurrent int | |||
| 71 | + | Quality string | |||
| 72 | + | Status string | |||
| 73 | + | API string | |||
| 74 | + | Encoders []string | |||
| 75 | + | Decoders []string | |||
| 61 | } | 76 | } | ||
| 62 | 77 | ||||
| 63 | // Factory returns the WebViewFactory for creating new WebView instances. | 78 | // Factory returns the WebViewFactory for creating new WebView instances. | ||
| @@ -299,6 +314,7 @@ func (e *Engine) SetHandlerContext(ctx context.Context) { | |||||
| 299 | if e.messageRouter != nil { | 314 | if e.messageRouter != nil { | ||
| 300 | e.messageRouter.SetBaseContext(ctx) | 315 | e.messageRouter.SetBaseContext(ctx) | ||
| 301 | } | 316 | } | ||
| 317 | + | e.logTranscoderStartupState() | |||
| 302 | } | 318 | } | ||
| 303 | 319 | ||||
| 304 | const browserCreateStallWarnMS int64 = 1500 | 320 | const browserCreateStallWarnMS int64 = 1500 | ||
| @@ -400,6 +416,43 @@ func (e *Engine) maybeLogBrowserCreateStall() { | |||||
| 400 | Msg("cef: browser creation appears stalled") | 416 | Msg("cef: browser creation appears stalled") | ||
| 401 | } | 417 | } | ||
| 402 | 418 | ||||
| 419 | + | func (e *Engine) currentContext() context.Context { | |||
| 420 | + | if e == nil || e.ctx == nil { | |||
| 421 | + | return context.Background() | |||
| 422 | + | } | |||
| 423 | + | return e.ctx | |||
| 424 | + | } | |||
| 425 | + | ||||
| 426 | + | func (e *Engine) logTranscoderStartupState() { | |||
| 427 | + | if e == nil || !e.transcoderStateLogged.CompareAndSwap(false, true) { | |||
| 428 | + | return | |||
| 429 | + | } | |||
| 430 | + | ||||
| 431 | + | state := e.transcoderState | |||
| 432 | + | log := logging.FromContext(e.currentContext()) | |||
| 433 | + | event := log.Info(). | |||
| 434 | + | Str("component", "cef-transcoder"). | |||
| 435 | + | Bool("config_enabled", state.ConfigEnabled). | |||
| 436 | + | Bool("probe_attempted", state.ProbeAttempted). | |||
| 437 | + | Str("hwaccel", state.HWAccel). | |||
| 438 | + | Int("max_concurrent", state.MaxConcurrent). | |||
| 439 | + | Str("quality", state.Quality). | |||
| 440 | + | Str("status", state.Status). | |||
| 441 | + | Bool("request_handler_enabled", e.factory != nil && e.factory.transcoder != nil) | |||
| 442 | + | ||||
| 443 | + | if state.API != "" { | |||
| 444 | + | event = event.Str("api", state.API) | |||
| 445 | + | } | |||
| 446 | + | if len(state.Encoders) > 0 { | |||
| 447 | + | event = event.Strs("encoders", state.Encoders) | |||
| 448 | + | } | |||
| 449 | + | if len(state.Decoders) > 0 { | |||
| 450 | + | event = event.Strs("decoders", state.Decoders) | |||
| 451 | + | } | |||
| 452 | + | ||||
| 453 | + | event.Msg("cef: transcoder startup state") | |||
| 454 | + | } | |||
| 455 | + | ||||
| 403 | // --------------------------------------------------------------------------- | 456 | // --------------------------------------------------------------------------- | ||
| 404 | // CEF message pump | 457 | // CEF message pump | ||
| 405 | // --------------------------------------------------------------------------- | 458 | // --------------------------------------------------------------------------- | ||
M internal/infrastructure/cef/engine_init.go +46 -7
| @@ -23,7 +23,7 @@ import ( | |||
| 23 | 23 | const puregoCEFInitTraceEnvVar = "PUREGO_CEF_INIT_TRACE" | |
| 24 | 24 | ||
| 25 | 25 | // NewEngine initializes the CEF runtime and returns a ready-to-use Engine. | |
| 26 | - | func NewEngine(ctx context.Context, cfg config.CEFEngineConfig) (*Engine, error) { | |
| 26 | + | func NewEngine(ctx context.Context, cfg config.CEFEngineConfig, transcodingCfg config.TranscodingConfig) (*Engine, error) { | |
| 27 | 27 | logger := logging.FromContext(ctx) | |
| 28 | 28 | cleanStaleSingletonLocks(logger) | |
| 29 | 29 | ||
| @@ -60,7 +60,7 @@ func NewEngine(ctx context.Context, cfg config.CEFEngineConfig) (*Engine, error) | |||
| 60 | 60 | } | |
| 61 | 61 | os.Args = savedArgs | |
| 62 | 62 | ||
| 63 | - | return wireEngine(ctx, eng, cfg, windowlessFrameRate, logger) | |
| 63 | + | return wireEngine(ctx, eng, cfg, transcodingCfg, windowlessFrameRate, logger) | |
| 64 | 64 | } | |
| 65 | 65 | ||
| 66 | 66 | // prepareCEFSettings builds purecef.Settings from the engine config. | |
| @@ -118,7 +118,7 @@ func initializeCEF(eng *Engine, settings purecef.Settings, multiThreaded bool, l | |||
| 118 | 118 | ||
| 119 | 119 | // wireEngine creates GL loader, factory, pool, and scheme handler after CEF init. | |
| 120 | 120 | func wireEngine( | |
| 121 | - | ctx context.Context, eng *Engine, cfg config.CEFEngineConfig, | |
| 121 | + | ctx context.Context, eng *Engine, cfg config.CEFEngineConfig, transcodingCfg config.TranscodingConfig, | |
| 122 | 122 | windowlessFrameRate int32, logger *zerolog.Logger, | |
| 123 | 123 | ) (*Engine, error) { | |
| 124 | 124 | gl, err := newGLLoader() | |
| @@ -140,17 +140,25 @@ func wireEngine( | |||
| 140 | 140 | } | |
| 141 | 141 | } | |
| 142 | 142 | ||
| 143 | - | // Initialize GPU transcoder if enabled in the global config. | |
| 143 | + | // Initialize the GPU transcoder when enabled in the loaded app config. | |
| 144 | 144 | var mediaTranscoder port.MediaTranscoder | |
| 145 | - | if appCfg := config.Get(); appCfg != nil && appCfg.Transcoding.Enabled { | |
| 146 | - | tc := transcoderpkg.New(appCfg.Transcoding, logger) | |
| 145 | + | transcoderState := buildTranscoderStartupState(transcodingCfg) | |
| 146 | + | if transcodingCfg.Enabled { | |
| 147 | + | transcoderState.ProbeAttempted = true | |
| 148 | + | tc := transcoderpkg.New(transcodingCfg, logger) | |
| 149 | + | caps := tc.Capabilities() | |
| 150 | + | transcoderState.API = caps.API | |
| 151 | + | transcoderState.Encoders = append([]string(nil), caps.Encoders...) | |
| 152 | + | transcoderState.Decoders = append([]string(nil), caps.Decoders...) | |
| 147 | 153 | if tc.Available() { | |
| 148 | 154 | mediaTranscoder = tc | |
| 155 | + | transcoderState.Status = "available" | |
| 149 | 156 | logger.Info(). | |
| 150 | 157 | Str("api", tc.Capabilities().API). | |
| 151 | 158 | Strs("encoders", tc.Capabilities().Encoders). | |
| 152 | 159 | Msg("cef: GPU transcoding available") | |
| 153 | 160 | } else { | |
| 161 | + | transcoderState.Status = "unavailable_no_compatible_gpu" | |
| 154 | 162 | logger.Warn().Msg("cef: GPU transcoding enabled but no compatible GPU found — feature disabled") | |
| 155 | 163 | } | |
| 156 | 164 | } | |
| @@ -167,9 +175,10 @@ func wireEngine( | |||
| 167 | 175 | eng.factory = factory | |
| 168 | 176 | eng.pool = pool | |
| 169 | 177 | eng.contentInj = newContentInjector(eng, nil) | |
| 178 | + | eng.transcoderState = transcoderState | |
| 170 | 179 | ||
| 171 | 180 | messageRouter := NewMessageRouter(ctx) | |
| 172 | - | schemeHandler := newDumbSchemeHandler(ctx, messageRouter) | |
| 181 | + | schemeHandler := newDumbSchemeHandler(ctx, messageRouter, mediaTranscoder) | |
| 173 | 182 | schemeHandler.setAssets(assets.WebUIAssets) | |
| 174 | 183 | ||
| 175 | 184 | schemeFactory := purecef.NewSchemeHandlerFactory(schemeHandler) | |
| @@ -199,6 +208,36 @@ func wireEngine( | |||
| 199 | 208 | return eng, nil | |
| 200 | 209 | } | |
| 201 | 210 | ||
| 211 | + | func buildTranscoderStartupState(cfg config.TranscodingConfig) transcoderStartupState { | |
| 212 | + | hwaccel := cfg.HWAccel | |
| 213 | + | if hwaccel == "" { | |
| 214 | + | hwaccel = "auto" | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | maxConcurrent := cfg.MaxConcurrent | |
| 218 | + | if maxConcurrent <= 0 { | |
| 219 | + | maxConcurrent = 2 | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | quality := cfg.Quality | |
| 223 | + | if quality == "" { | |
| 224 | + | quality = "medium" | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | status := "disabled_in_config" | |
| 228 | + | if cfg.Enabled { | |
| 229 | + | status = "configured_enabled" | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | return transcoderStartupState{ | |
| 233 | + | ConfigEnabled: cfg.Enabled, | |
| 234 | + | HWAccel: hwaccel, | |
| 235 | + | MaxConcurrent: maxConcurrent, | |
| 236 | + | Quality: quality, | |
| 237 | + | Status: status, | |
| 238 | + | } | |
| 239 | + | } | |
| 240 | + | ||
| 202 | 241 | // getPrimaryMonitor returns the primary GDK monitor, or nil if unavailable. | |
| 203 | 242 | func getPrimaryMonitor() *gdk.Monitor { | |
| 204 | 243 | display := gdk.DisplayGetDefault() | |
| @@ -23,7 +23,7 @@ import ( | |||||
| 23 | const puregoCEFInitTraceEnvVar = "PUREGO_CEF_INIT_TRACE" | 23 | const puregoCEFInitTraceEnvVar = "PUREGO_CEF_INIT_TRACE" | ||
| 24 | 24 | ||||
| 25 | // NewEngine initializes the CEF runtime and returns a ready-to-use Engine. | 25 | // NewEngine initializes the CEF runtime and returns a ready-to-use Engine. | ||
| 26 | - | func NewEngine(ctx context.Context, cfg config.CEFEngineConfig) (*Engine, error) { | 26 | + | func NewEngine(ctx context.Context, cfg config.CEFEngineConfig, transcodingCfg config.TranscodingConfig) (*Engine, error) { |
| 27 | logger := logging.FromContext(ctx) | 27 | logger := logging.FromContext(ctx) | ||
| 28 | cleanStaleSingletonLocks(logger) | 28 | cleanStaleSingletonLocks(logger) | ||
| 29 | 29 | ||||
| @@ -60,7 +60,7 @@ func NewEngine(ctx context.Context, cfg config.CEFEngineConfig) (*Engine, error) | |||||
| 60 | } | 60 | } | ||
| 61 | os.Args = savedArgs | 61 | os.Args = savedArgs | ||
| 62 | 62 | ||||
| 63 | - | return wireEngine(ctx, eng, cfg, windowlessFrameRate, logger) | 63 | + | return wireEngine(ctx, eng, cfg, transcodingCfg, windowlessFrameRate, logger) |
| 64 | } | 64 | } | ||
| 65 | 65 | ||||
| 66 | // prepareCEFSettings builds purecef.Settings from the engine config. | 66 | // prepareCEFSettings builds purecef.Settings from the engine config. | ||
| @@ -118,7 +118,7 @@ func initializeCEF(eng *Engine, settings purecef.Settings, multiThreaded bool, l | |||||
| 118 | 118 | ||||
| 119 | // wireEngine creates GL loader, factory, pool, and scheme handler after CEF init. | 119 | // wireEngine creates GL loader, factory, pool, and scheme handler after CEF init. | ||
| 120 | func wireEngine( | 120 | func wireEngine( | ||
| 121 | - | ctx context.Context, eng *Engine, cfg config.CEFEngineConfig, | 121 | + | ctx context.Context, eng *Engine, cfg config.CEFEngineConfig, transcodingCfg config.TranscodingConfig, |
| 122 | windowlessFrameRate int32, logger *zerolog.Logger, | 122 | windowlessFrameRate int32, logger *zerolog.Logger, | ||
| 123 | ) (*Engine, error) { | 123 | ) (*Engine, error) { | ||
| 124 | gl, err := newGLLoader() | 124 | gl, err := newGLLoader() | ||
| @@ -140,17 +140,25 @@ func wireEngine( | |||||
| 140 | } | 140 | } | ||
| 141 | } | 141 | } | ||
| 142 | 142 | ||||
| 143 | - | // Initialize GPU transcoder if enabled in the global config. | 143 | + | // Initialize the GPU transcoder when enabled in the loaded app config. |
| 144 | var mediaTranscoder port.MediaTranscoder | 144 | var mediaTranscoder port.MediaTranscoder | ||
| 145 | - | if appCfg := config.Get(); appCfg != nil && appCfg.Transcoding.Enabled { | 145 | + | transcoderState := buildTranscoderStartupState(transcodingCfg) |
| 146 | - | tc := transcoderpkg.New(appCfg.Transcoding, logger) | 146 | + | if transcodingCfg.Enabled { |
| 147 | + | transcoderState.ProbeAttempted = true | |||
| 148 | + | tc := transcoderpkg.New(transcodingCfg, logger) | |||
| 149 | + | caps := tc.Capabilities() | |||
| 150 | + | transcoderState.API = caps.API | |||
| 151 | + | transcoderState.Encoders = append([]string(nil), caps.Encoders...) | |||
| 152 | + | transcoderState.Decoders = append([]string(nil), caps.Decoders...) | |||
| 147 | if tc.Available() { | 153 | if tc.Available() { | ||
| 148 | mediaTranscoder = tc | 154 | mediaTranscoder = tc | ||
| 155 | + | transcoderState.Status = "available" | |||
| 149 | logger.Info(). | 156 | logger.Info(). | ||
| 150 | Str("api", tc.Capabilities().API). | 157 | Str("api", tc.Capabilities().API). | ||
| 151 | Strs("encoders", tc.Capabilities().Encoders). | 158 | Strs("encoders", tc.Capabilities().Encoders). | ||
| 152 | Msg("cef: GPU transcoding available") | 159 | Msg("cef: GPU transcoding available") | ||
| 153 | } else { | 160 | } else { | ||
| 161 | + | transcoderState.Status = "unavailable_no_compatible_gpu" | |||
| 154 | logger.Warn().Msg("cef: GPU transcoding enabled but no compatible GPU found — feature disabled") | 162 | logger.Warn().Msg("cef: GPU transcoding enabled but no compatible GPU found — feature disabled") | ||
| 155 | } | 163 | } | ||
| 156 | } | 164 | } | ||
| @@ -167,9 +175,10 @@ func wireEngine( | |||||
| 167 | eng.factory = factory | 175 | eng.factory = factory | ||
| 168 | eng.pool = pool | 176 | eng.pool = pool | ||
| 169 | eng.contentInj = newContentInjector(eng, nil) | 177 | eng.contentInj = newContentInjector(eng, nil) | ||
| 178 | + | eng.transcoderState = transcoderState | |||
| 170 | 179 | ||||
| 171 | messageRouter := NewMessageRouter(ctx) | 180 | messageRouter := NewMessageRouter(ctx) | ||
| 172 | - | schemeHandler := newDumbSchemeHandler(ctx, messageRouter) | 181 | + | schemeHandler := newDumbSchemeHandler(ctx, messageRouter, mediaTranscoder) |
| 173 | schemeHandler.setAssets(assets.WebUIAssets) | 182 | schemeHandler.setAssets(assets.WebUIAssets) | ||
| 174 | 183 | ||||
| 175 | schemeFactory := purecef.NewSchemeHandlerFactory(schemeHandler) | 184 | schemeFactory := purecef.NewSchemeHandlerFactory(schemeHandler) | ||
| @@ -199,6 +208,36 @@ func wireEngine( | |||||
| 199 | return eng, nil | 208 | return eng, nil | ||
| 200 | } | 209 | } | ||
| 201 | 210 | ||||
| 211 | + | func buildTranscoderStartupState(cfg config.TranscodingConfig) transcoderStartupState { | |||
| 212 | + | hwaccel := cfg.HWAccel | |||
| 213 | + | if hwaccel == "" { | |||
| 214 | + | hwaccel = "auto" | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | maxConcurrent := cfg.MaxConcurrent | |||
| 218 | + | if maxConcurrent <= 0 { | |||
| 219 | + | maxConcurrent = 2 | |||
| 220 | + | } | |||
| 221 | + | ||||
| 222 | + | quality := cfg.Quality | |||
| 223 | + | if quality == "" { | |||
| 224 | + | quality = "medium" | |||
| 225 | + | } | |||
| 226 | + | ||||
| 227 | + | status := "disabled_in_config" | |||
| 228 | + | if cfg.Enabled { | |||
| 229 | + | status = "configured_enabled" | |||
| 230 | + | } | |||
| 231 | + | ||||
| 232 | + | return transcoderStartupState{ | |||
| 233 | + | ConfigEnabled: cfg.Enabled, | |||
| 234 | + | HWAccel: hwaccel, | |||
| 235 | + | MaxConcurrent: maxConcurrent, | |||
| 236 | + | Quality: quality, | |||
| 237 | + | Status: status, | |||
| 238 | + | } | |||
| 239 | + | } | |||
| 240 | + | ||||
| 202 | // getPrimaryMonitor returns the primary GDK monitor, or nil if unavailable. | 241 | // getPrimaryMonitor returns the primary GDK monitor, or nil if unavailable. | ||
| 203 | func getPrimaryMonitor() *gdk.Monitor { | 242 | func getPrimaryMonitor() *gdk.Monitor { | ||
| 204 | display := gdk.DisplayGetDefault() | 243 | display := gdk.DisplayGetDefault() | ||
M internal/infrastructure/cef/factory.go +6 -1
| @@ -77,7 +77,12 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) { | |||
| 77 | 77 | ||
| 78 | 78 | var transcodingHandler purecef.ResourceRequestHandler | |
| 79 | 79 | if f.transcoder != nil { | |
| 80 | - | transcodingHandler = newTranscodingRequestHandler(f.transcoder) | |
| 80 | + | transcodingHandler = newTranscodingRequestHandler(f.transcoder, func() context.Context { | |
| 81 | + | if f.engine != nil { | |
| 82 | + | return f.engine.currentContext() | |
| 83 | + | } | |
| 84 | + | return ctx | |
| 85 | + | }) | |
| 81 | 86 | } | |
| 82 | 87 | ||
| 83 | 88 | handlers := &handlerSet{ | |
| @@ -77,7 +77,12 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) { | |||||
| 77 | 77 | ||||
| 78 | var transcodingHandler purecef.ResourceRequestHandler | 78 | var transcodingHandler purecef.ResourceRequestHandler | ||
| 79 | if f.transcoder != nil { | 79 | if f.transcoder != nil { | ||
| 80 | - | transcodingHandler = newTranscodingRequestHandler(f.transcoder) | 80 | + | transcodingHandler = newTranscodingRequestHandler(f.transcoder, func() context.Context { |
| 81 | + | if f.engine != nil { | |||
| 82 | + | return f.engine.currentContext() | |||
| 83 | + | } | |||
| 84 | + | return ctx | |||
| 85 | + | }) | |||
| 81 | } | 86 | } | ||
| 82 | 87 | ||||
| 83 | handlers := &handlerSet{ | 88 | handlers := &handlerSet{ | ||
M internal/infrastructure/cef/scheme_handler.go +59 -1
| @@ -14,6 +14,7 @@ import ( | |||
| 14 | 14 | "path/filepath" | |
| 15 | 15 | "strings" | |
| 16 | 16 | "sync" | |
| 17 | + | "time" | |
| 17 | 18 | "unsafe" | |
| 18 | 19 | ||
| 19 | 20 | purecef "github.com/bnema/purego-cef/cef" | |
| @@ -39,6 +40,7 @@ const ( | |||
| 39 | 40 | type dumbSchemeHandler struct { | |
| 40 | 41 | ctx context.Context | |
| 41 | 42 | messageRouter *MessageRouter | |
| 43 | + | transcoder port.MediaTranscoder | |
| 42 | 44 | assets embed.FS | |
| 43 | 45 | assetsSet bool | |
| 44 | 46 | assetDir string | |
| @@ -48,11 +50,12 @@ type dumbSchemeHandler struct { | |||
| 48 | 50 | } | |
| 49 | 51 | ||
| 50 | 52 | // newDumbSchemeHandler creates a handler for internal CEF pages. | |
| 51 | - | func newDumbSchemeHandler(ctx context.Context, router *MessageRouter) *dumbSchemeHandler { | |
| 53 | + | func newDumbSchemeHandler(ctx context.Context, router *MessageRouter, transcoder port.MediaTranscoder) *dumbSchemeHandler { | |
| 52 | 54 | log := logging.FromContext(ctx) | |
| 53 | 55 | return &dumbSchemeHandler{ | |
| 54 | 56 | ctx: ctx, | |
| 55 | 57 | messageRouter: router, | |
| 58 | + | transcoder: transcoder, | |
| 56 | 59 | assetDir: "webui", | |
| 57 | 60 | logger: log.With().Str("component", "scheme-handler").Logger(), | |
| 58 | 61 | hwSurveyor: env.NewHardwareSurveyor(), | |
| @@ -130,11 +133,66 @@ func (h *dumbSchemeHandler) handleAPI(method, path string, request purecef.Reque | |||
| 130 | 133 | case path == "/api/config/default" && (method == "" || strings.EqualFold(method, "GET")): | |
| 131 | 134 | return h.handleConfigAPI(config.DefaultConfig()) | |
| 132 | 135 | ||
| 136 | + | case path == "/api/transcode" && (method == "" || strings.EqualFold(method, "GET")): | |
| 137 | + | return h.handleTranscodeAPI(request) | |
| 138 | + | ||
| 133 | 139 | default: | |
| 134 | 140 | return h.newJSONResourceHandler(http.StatusNotFound, map[string]string{"error": "not found"}) | |
| 135 | 141 | } | |
| 136 | 142 | } | |
| 137 | 143 | ||
| 144 | + | func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef.ResourceHandler { | |
| 145 | + | if h.transcoder == nil { | |
| 146 | + | return h.newJSONResourceHandler(http.StatusServiceUnavailable, map[string]string{"error": "transcoder unavailable"}) | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | reqURL := request.GetURL() | |
| 150 | + | parsed, err := url.Parse(reqURL) | |
| 151 | + | if err != nil { | |
| 152 | + | return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid request URL"}) | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | sourceURL := strings.TrimSpace(parsed.Query().Get("src")) | |
| 156 | + | if sourceURL == "" { | |
| 157 | + | return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "missing src"}) | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | sourceParsed, err := url.Parse(sourceURL) | |
| 161 | + | if err != nil || sourceParsed.Host == "" || (sourceParsed.Scheme != "http" && sourceParsed.Scheme != "https") { | |
| 162 | + | return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid src"}) | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | headers := make(map[string]string) | |
| 166 | + | if userAgent := request.GetHeaderByName("User-Agent"); userAgent != "" { | |
| 167 | + | headers["User-Agent"] = userAgent | |
| 168 | + | } | |
| 169 | + | if referer := strings.TrimSpace(parsed.Query().Get("referer")); referer != "" { | |
| 170 | + | headers["Referer"] = referer | |
| 171 | + | } else if referer := request.GetReferrerURL(); referer != "" { | |
| 172 | + | headers["Referer"] = referer | |
| 173 | + | } | |
| 174 | + | if origin := strings.TrimSpace(parsed.Query().Get("origin")); origin != "" { | |
| 175 | + | headers["Origin"] = origin | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) | |
| 179 | + | h.logger.Info(). | |
| 180 | + | Str("source_url", logging.TruncateURL(sourceURL, 240)). | |
| 181 | + | Int("forwarded_header_count", len(headers)). | |
| 182 | + | Msg("scheme handler returning transcoding stream") | |
| 183 | + | ||
| 184 | + | return purecef.NewResourceHandler(&transcodingResourceHandler{ | |
| 185 | + | transcoder: h.transcoder, | |
| 186 | + | sourceURL: sourceURL, | |
| 187 | + | headers: headers, | |
| 188 | + | ctx: ctx, | |
| 189 | + | cancel: cancel, | |
| 190 | + | logf: func() zerolog.Logger { | |
| 191 | + | return h.logger.With().Str("component", "scheme-transcoding").Logger() | |
| 192 | + | }, | |
| 193 | + | }) | |
| 194 | + | } | |
| 195 | + | ||
| 138 | 196 | // handleConfigAPI returns a JSON response with the given config. | |
| 139 | 197 | func (h *dumbSchemeHandler) handleConfigAPI(cfg *config.Config) purecef.ResourceHandler { | |
| 140 | 198 | var hwInfo *port.HardwareInfo | |
| @@ -14,6 +14,7 @@ import ( | |||||
| 14 | "path/filepath" | 14 | "path/filepath" | ||
| 15 | "strings" | 15 | "strings" | ||
| 16 | "sync" | 16 | "sync" | ||
| 17 | + | "time" | |||
| 17 | "unsafe" | 18 | "unsafe" | ||
| 18 | 19 | ||||
| 19 | purecef "github.com/bnema/purego-cef/cef" | 20 | purecef "github.com/bnema/purego-cef/cef" | ||
| @@ -39,6 +40,7 @@ const ( | |||||
| 39 | type dumbSchemeHandler struct { | 40 | type dumbSchemeHandler struct { | ||
| 40 | ctx context.Context | 41 | ctx context.Context | ||
| 41 | messageRouter *MessageRouter | 42 | messageRouter *MessageRouter | ||
| 43 | + | transcoder port.MediaTranscoder | |||
| 42 | assets embed.FS | 44 | assets embed.FS | ||
| 43 | assetsSet bool | 45 | assetsSet bool | ||
| 44 | assetDir string | 46 | assetDir string | ||
| @@ -48,11 +50,12 @@ type dumbSchemeHandler struct { | |||||
| 48 | } | 50 | } | ||
| 49 | 51 | ||||
| 50 | // newDumbSchemeHandler creates a handler for internal CEF pages. | 52 | // newDumbSchemeHandler creates a handler for internal CEF pages. | ||
| 51 | - | func newDumbSchemeHandler(ctx context.Context, router *MessageRouter) *dumbSchemeHandler { | 53 | + | func newDumbSchemeHandler(ctx context.Context, router *MessageRouter, transcoder port.MediaTranscoder) *dumbSchemeHandler { |
| 52 | log := logging.FromContext(ctx) | 54 | log := logging.FromContext(ctx) | ||
| 53 | return &dumbSchemeHandler{ | 55 | return &dumbSchemeHandler{ | ||
| 54 | ctx: ctx, | 56 | ctx: ctx, | ||
| 55 | messageRouter: router, | 57 | messageRouter: router, | ||
| 58 | + | transcoder: transcoder, | |||
| 56 | assetDir: "webui", | 59 | assetDir: "webui", | ||
| 57 | logger: log.With().Str("component", "scheme-handler").Logger(), | 60 | logger: log.With().Str("component", "scheme-handler").Logger(), | ||
| 58 | hwSurveyor: env.NewHardwareSurveyor(), | 61 | hwSurveyor: env.NewHardwareSurveyor(), | ||
| @@ -130,11 +133,66 @@ func (h *dumbSchemeHandler) handleAPI(method, path string, request purecef.Reque | |||||
| 130 | case path == "/api/config/default" && (method == "" || strings.EqualFold(method, "GET")): | 133 | case path == "/api/config/default" && (method == "" || strings.EqualFold(method, "GET")): | ||
| 131 | return h.handleConfigAPI(config.DefaultConfig()) | 134 | return h.handleConfigAPI(config.DefaultConfig()) | ||
| 132 | 135 | ||||
| 136 | + | case path == "/api/transcode" && (method == "" || strings.EqualFold(method, "GET")): | |||
| 137 | + | return h.handleTranscodeAPI(request) | |||
| 138 | + | ||||
| 133 | default: | 139 | default: | ||
| 134 | return h.newJSONResourceHandler(http.StatusNotFound, map[string]string{"error": "not found"}) | 140 | return h.newJSONResourceHandler(http.StatusNotFound, map[string]string{"error": "not found"}) | ||
| 135 | } | 141 | } | ||
| 136 | } | 142 | } | ||
| 137 | 143 | ||||
| 144 | + | func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef.ResourceHandler { | |||
| 145 | + | if h.transcoder == nil { | |||
| 146 | + | return h.newJSONResourceHandler(http.StatusServiceUnavailable, map[string]string{"error": "transcoder unavailable"}) | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | reqURL := request.GetURL() | |||
| 150 | + | parsed, err := url.Parse(reqURL) | |||
| 151 | + | if err != nil { | |||
| 152 | + | return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid request URL"}) | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | sourceURL := strings.TrimSpace(parsed.Query().Get("src")) | |||
| 156 | + | if sourceURL == "" { | |||
| 157 | + | return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "missing src"}) | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | sourceParsed, err := url.Parse(sourceURL) | |||
| 161 | + | if err != nil || sourceParsed.Host == "" || (sourceParsed.Scheme != "http" && sourceParsed.Scheme != "https") { | |||
| 162 | + | return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid src"}) | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | headers := make(map[string]string) | |||
| 166 | + | if userAgent := request.GetHeaderByName("User-Agent"); userAgent != "" { | |||
| 167 | + | headers["User-Agent"] = userAgent | |||
| 168 | + | } | |||
| 169 | + | if referer := strings.TrimSpace(parsed.Query().Get("referer")); referer != "" { | |||
| 170 | + | headers["Referer"] = referer | |||
| 171 | + | } else if referer := request.GetReferrerURL(); referer != "" { | |||
| 172 | + | headers["Referer"] = referer | |||
| 173 | + | } | |||
| 174 | + | if origin := strings.TrimSpace(parsed.Query().Get("origin")); origin != "" { | |||
| 175 | + | headers["Origin"] = origin | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) | |||
| 179 | + | h.logger.Info(). | |||
| 180 | + | Str("source_url", logging.TruncateURL(sourceURL, 240)). | |||
| 181 | + | Int("forwarded_header_count", len(headers)). | |||
| 182 | + | Msg("scheme handler returning transcoding stream") | |||
| 183 | + | ||||
| 184 | + | return purecef.NewResourceHandler(&transcodingResourceHandler{ | |||
| 185 | + | transcoder: h.transcoder, | |||
| 186 | + | sourceURL: sourceURL, | |||
| 187 | + | headers: headers, | |||
| 188 | + | ctx: ctx, | |||
| 189 | + | cancel: cancel, | |||
| 190 | + | logf: func() zerolog.Logger { | |||
| 191 | + | return h.logger.With().Str("component", "scheme-transcoding").Logger() | |||
| 192 | + | }, | |||
| 193 | + | }) | |||
| 194 | + | } | |||
| 195 | + | ||||
| 138 | // handleConfigAPI returns a JSON response with the given config. | 196 | // handleConfigAPI returns a JSON response with the given config. | ||
| 139 | func (h *dumbSchemeHandler) handleConfigAPI(cfg *config.Config) purecef.ResourceHandler { | 197 | func (h *dumbSchemeHandler) handleConfigAPI(cfg *config.Config) purecef.ResourceHandler { | ||
| 140 | var hwInfo *port.HardwareInfo | 198 | var hwInfo *port.HardwareInfo | ||
M internal/infrastructure/cef/transcoding_handler.go +174 -30
| @@ -2,6 +2,8 @@ package cef | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "io" | |
| 6 | + | "strings" | |
| 5 | 7 | "sync" | |
| 6 | 8 | "time" | |
| 7 | 9 | "unsafe" | |
| @@ -10,6 +12,8 @@ import ( | |||
| 10 | 12 | ||
| 11 | 13 | "github.com/bnema/dumber/internal/application/port" | |
| 12 | 14 | "github.com/bnema/dumber/internal/infrastructure/transcoder" | |
| 15 | + | "github.com/bnema/dumber/internal/logging" | |
| 16 | + | "github.com/rs/zerolog" | |
| 13 | 17 | ) | |
| 14 | 18 | ||
| 15 | 19 | // --------------------------------------------------------------------------- | |
| @@ -32,6 +36,8 @@ type transcodingResourceHandler struct { | |||
| 32 | 36 | session port.TranscodeSession | |
| 33 | 37 | ctx context.Context | |
| 34 | 38 | cancel context.CancelFunc | |
| 39 | + | logf func() zerolog.Logger | |
| 40 | + | totalBytes int64 | |
| 35 | 41 | } | |
| 36 | 42 | ||
| 37 | 43 | // Open starts the transcode session asynchronously. CEF will call | |
| @@ -40,14 +46,28 @@ func (rh *transcodingResourceHandler) Open(_ purecef.Request, handleRequest unsa | |||
| 40 | 46 | // Signal async handling: set handleRequest = 0. | |
| 41 | 47 | *(*int32)(handleRequest) = 0 | |
| 42 | 48 | ||
| 49 | + | log := rh.logger() | |
| 50 | + | log.Info(). | |
| 51 | + | Str("source_url", logging.TruncateURL(rh.sourceURL, 240)). | |
| 52 | + | Int("forwarded_header_count", len(rh.headers)). | |
| 53 | + | Msg("cef: starting transcoding resource stream") | |
| 54 | + | ||
| 43 | 55 | go func() { | |
| 44 | 56 | session, err := rh.transcoder.Start(rh.ctx, rh.sourceURL, rh.headers) | |
| 45 | 57 | if err != nil { | |
| 58 | + | log.Error(). | |
| 59 | + | Err(err). | |
| 60 | + | Str("source_url", logging.TruncateURL(rh.sourceURL, 240)). | |
| 61 | + | Msg("cef: failed to start transcoding resource stream") | |
| 46 | 62 | // Let CEF know we failed — Cancel will be called. | |
| 47 | 63 | rh.cancel() | |
| 48 | 64 | return | |
| 49 | 65 | } | |
| 50 | 66 | rh.session = session | |
| 67 | + | log.Info(). | |
| 68 | + | Str("source_url", logging.TruncateURL(rh.sourceURL, 240)). | |
| 69 | + | Str("content_type", session.ContentType()). | |
| 70 | + | Msg("cef: transcoding resource stream ready") | |
| 51 | 71 | callback.Cont() | |
| 52 | 72 | }() | |
| 53 | 73 | ||
| @@ -60,6 +80,8 @@ func (rh *transcodingResourceHandler) GetResponseHeaders(response purecef.Respon | |||
| 60 | 80 | response.SetStatusText("OK") | |
| 61 | 81 | response.SetMimeType("video/webm") | |
| 62 | 82 | response.SetHeaderByName("Accept-Ranges", "none", 1) | |
| 83 | + | response.SetHeaderByName("Access-Control-Allow-Origin", "*", 1) | |
| 84 | + | response.SetHeaderByName("Cache-Control", "no-store", 1) | |
| 63 | 85 | // Streaming — unknown length. | |
| 64 | 86 | *(*int64)(responseLength) = -1 | |
| 65 | 87 | } | |
| @@ -76,10 +98,29 @@ func (rh *transcodingResourceHandler) Read( | |||
| 76 | 98 | dst := unsafe.Slice((*byte)(dataOut), int(bytesToRead)) | |
| 77 | 99 | n, err := rh.session.Read(dst) | |
| 78 | 100 | if n > 0 { | |
| 101 | + | rh.totalBytes += int64(n) | |
| 102 | + | if rh.totalBytes == int64(n) { | |
| 103 | + | rh.logger().Info(). | |
| 104 | + | Str("source_url", logging.TruncateURL(rh.sourceURL, 240)). | |
| 105 | + | Int("first_chunk_bytes", n). | |
| 106 | + | Msg("cef: transcoding resource stream produced first bytes") | |
| 107 | + | } | |
| 79 | 108 | *(*int32)(bytesRead) = int32(n) | |
| 80 | 109 | return 1 | |
| 81 | 110 | } | |
| 82 | 111 | if err != nil { | |
| 112 | + | log := rh.logger().With(). | |
| 113 | + | Str("source_url", logging.TruncateURL(rh.sourceURL, 240)). | |
| 114 | + | Int64("total_bytes", rh.totalBytes). | |
| 115 | + | Logger() | |
| 116 | + | switch { | |
| 117 | + | case err == io.EOF && rh.totalBytes == 0: | |
| 118 | + | log.Warn().Msg("cef: transcoding resource stream ended before producing data") | |
| 119 | + | case err == io.EOF: | |
| 120 | + | log.Debug().Msg("cef: transcoding resource stream reached EOF") | |
| 121 | + | default: | |
| 122 | + | log.Error().Err(err).Msg("cef: transcoding resource stream read failed") | |
| 123 | + | } | |
| 83 | 124 | return 0 // EOF or error | |
| 84 | 125 | } | |
| 85 | 126 | return 0 | |
| @@ -87,6 +128,10 @@ func (rh *transcodingResourceHandler) Read( | |||
| 87 | 128 | ||
| 88 | 129 | // Cancel aborts the transcode session and releases resources. | |
| 89 | 130 | func (rh *transcodingResourceHandler) Cancel() { | |
| 131 | + | rh.logger().Debug(). | |
| 132 | + | Str("source_url", logging.TruncateURL(rh.sourceURL, 240)). | |
| 133 | + | Int64("total_bytes", rh.totalBytes). | |
| 134 | + | Msg("cef: transcoding resource stream cancelled") | |
| 90 | 135 | if rh.session != nil { | |
| 91 | 136 | rh.session.Close() | |
| 92 | 137 | } | |
| @@ -112,12 +157,14 @@ func (rh *transcodingResourceHandler) Skip(_ int64, _ unsafe.Pointer, _ purecef. | |||
| 112 | 157 | ||
| 113 | 158 | // --------------------------------------------------------------------------- | |
| 114 | 159 | // transcodingRequestHandler — ResourceRequestHandler that intercepts | |
| 115 | - | // proprietary video responses and restarts them through the transcoder | |
| 160 | + | // proprietary video responses and streaming manifests, then restarts them | |
| 161 | + | // through the transcoder | |
| 116 | 162 | // --------------------------------------------------------------------------- | |
| 117 | 163 | ||
| 118 | 164 | type transcodingRequestHandler struct { | |
| 119 | 165 | transcoder port.MediaTranscoder | |
| 120 | 166 | transcodableURLs sync.Map // url string -> requestInfo | |
| 167 | + | ctxf func() context.Context | |
| 121 | 168 | } | |
| 122 | 169 | ||
| 123 | 170 | type requestInfo struct { | |
| @@ -125,69 +172,112 @@ type requestInfo struct { | |||
| 125 | 172 | } | |
| 126 | 173 | ||
| 127 | 174 | // newTranscodingRequestHandler creates a ResourceRequestHandler that detects | |
| 128 | - | // proprietary video MIME types and restarts those requests through a GPU | |
| 129 | - | // transcoding ResourceHandler. | |
| 130 | - | func newTranscodingRequestHandler(tc port.MediaTranscoder) purecef.ResourceRequestHandler { | |
| 175 | + | // proprietary video containers and HLS/DASH manifest entrypoints, then | |
| 176 | + | // restarts those requests through a GPU transcoding ResourceHandler. | |
| 177 | + | func newTranscodingRequestHandler(tc port.MediaTranscoder, ctxf func() context.Context) purecef.ResourceRequestHandler { | |
| 131 | 178 | return purecef.NewResourceRequestHandler(&transcodingRequestHandler{ | |
| 132 | 179 | transcoder: tc, | |
| 180 | + | ctxf: ctxf, | |
| 133 | 181 | }) | |
| 134 | 182 | } | |
| 135 | 183 | ||
| 136 | - | // OnResourceResponse inspects the MIME type of every response. If it matches | |
| 137 | - | // a proprietary video format (H.264, HEVC, etc.), it stores the request info | |
| 138 | - | // and returns 1 to restart the request — at which point GetResourceHandler | |
| 139 | - | // will provide the transcoding handler. | |
| 184 | + | // OnResourceResponse inspects the MIME type and URL of every response. If it | |
| 185 | + | // matches a proprietary video format or a streaming manifest entrypoint, it | |
| 186 | + | // stores the request info and returns 1 to restart the request — at which | |
| 187 | + | // point GetResourceHandler will provide the transcoding handler. | |
| 140 | 188 | func (h *transcodingRequestHandler) OnResourceResponse(_ purecef.Browser, _ purecef.Frame, request purecef.Request, response purecef.Response) int32 { | |
| 141 | - | if response == nil { | |
| 189 | + | if response == nil || request == nil { | |
| 142 | 190 | return 0 | |
| 143 | 191 | } | |
| 144 | 192 | mimeType := response.GetMimeType() | |
| 145 | - | if !transcoder.IsProprietaryVideoMIME(mimeType) { | |
| 146 | - | return 0 | |
| 147 | - | } | |
| 193 | + | url := request.GetURL() | |
| 148 | 194 | ||
| 149 | - | // Extract relevant headers from the original request so the transcoder | |
| 150 | - | // can authenticate with the source server. | |
| 151 | - | headers := make(map[string]string) | |
| 152 | - | if cookie := request.GetHeaderByName("Cookie"); cookie != "" { | |
| 153 | - | headers["Cookie"] = cookie | |
| 195 | + | log := h.logger() | |
| 196 | + | isProprietary := transcoder.IsProprietaryVideoMIME(mimeType) | |
| 197 | + | isAlreadyOpen := transcoder.IsOpenVideoMIME(mimeType) | |
| 198 | + | isManifest := !isAlreadyOpen && (transcoder.IsStreamingManifestMIME(mimeType) || transcoder.IsStreamingManifestURL(url)) | |
| 199 | + | if isLikelyMediaResponse(url, mimeType) { | |
| 200 | + | log.Debug(). | |
| 201 | + | Str("url", logging.TruncateURL(url, 240)). | |
| 202 | + | Str("mime_type", mimeType). | |
| 203 | + | Bool("proprietary_match", isProprietary). | |
| 204 | + | Bool("already_open", isAlreadyOpen). | |
| 205 | + | Bool("manifest_match", isManifest). | |
| 206 | + | Msg("cef: OnResourceResponse observed media response") | |
| 154 | 207 | } | |
| 155 | - | if auth := request.GetHeaderByName("Authorization"); auth != "" { | |
| 156 | - | headers["Authorization"] = auth | |
| 208 | + | ||
| 209 | + | if isAlreadyOpen { | |
| 210 | + | return 0 | |
| 157 | 211 | } | |
| 158 | - | if referer := request.GetHeaderByName("Referer"); referer != "" { | |
| 159 | - | headers["Referer"] = referer | |
| 212 | + | ||
| 213 | + | if !isProprietary && !isManifest { | |
| 214 | + | return 0 | |
| 160 | 215 | } | |
| 161 | 216 | ||
| 162 | - | url := request.GetURL() | |
| 163 | - | h.transcodableURLs.Store(url, requestInfo{headers: headers}) | |
| 217 | + | info := buildRequestInfo(request) | |
| 218 | + | h.transcodableURLs.Store(url, info) | |
| 219 | + | reason := "proprietary-container" | |
| 220 | + | if isManifest { | |
| 221 | + | reason = "streaming-manifest" | |
| 222 | + | } | |
| 223 | + | log.Info(). | |
| 224 | + | Str("url", logging.TruncateURL(url, 240)). | |
| 225 | + | Str("mime_type", mimeType). | |
| 226 | + | Str("transcode_reason", reason). | |
| 227 | + | Int("forwarded_header_count", len(info.headers)). | |
| 228 | + | Msg("cef: media response marked for transcoding") | |
| 164 | 229 | ||
| 165 | 230 | return 1 // restart request | |
| 166 | 231 | } | |
| 167 | 232 | ||
| 168 | 233 | // GetResourceHandler returns a transcoding ResourceHandler for URLs that were | |
| 169 | - | // previously identified as proprietary video by OnResourceResponse. | |
| 234 | + | // either identified eagerly by URL heuristics or marked by OnResourceResponse. | |
| 170 | 235 | func (h *transcodingRequestHandler) GetResourceHandler(_ purecef.Browser, _ purecef.Frame, request purecef.Request) purecef.ResourceHandler { | |
| 171 | 236 | if request == nil { | |
| 172 | 237 | return nil | |
| 173 | 238 | } | |
| 174 | 239 | url := request.GetURL() | |
| 175 | 240 | val, ok := h.transcodableURLs.LoadAndDelete(url) | |
| 176 | - | if !ok { | |
| 177 | - | return nil | |
| 178 | - | } | |
| 179 | - | info, ok := val.(requestInfo) | |
| 180 | - | if !ok { | |
| 241 | + | info := requestInfo{} | |
| 242 | + | eager := false | |
| 243 | + | sourceURL := url | |
| 244 | + | if ok { | |
| 245 | + | cached, typeOK := val.(requestInfo) | |
| 246 | + | if !typeOK { | |
| 247 | + | return nil | |
| 248 | + | } | |
| 249 | + | info = cached | |
| 250 | + | } else if syntheticSourceURL, referer, origin, synthetic := transcoder.ParseSyntheticTranscodeURL(url); synthetic { | |
| 251 | + | eager = true | |
| 252 | + | sourceURL = syntheticSourceURL | |
| 253 | + | info = buildRequestInfo(request) | |
| 254 | + | if referer != "" && info.headers["Referer"] == "" { | |
| 255 | + | info.headers["Referer"] = referer | |
| 256 | + | } | |
| 257 | + | if origin != "" && info.headers["Origin"] == "" { | |
| 258 | + | info.headers["Origin"] = origin | |
| 259 | + | } | |
| 260 | + | } else if transcoder.IsEagerTranscodeURL(url) { | |
| 261 | + | eager = true | |
| 262 | + | info = buildRequestInfo(request) | |
| 263 | + | } else { | |
| 181 | 264 | return nil | |
| 182 | 265 | } | |
| 183 | 266 | ||
| 184 | 267 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) | |
| 268 | + | h.logger().Info(). | |
| 269 | + | Str("url", logging.TruncateURL(url, 240)). | |
| 270 | + | Str("source_url", logging.TruncateURL(sourceURL, 240)). | |
| 271 | + | Bool("eager_match", eager). | |
| 272 | + | Int("forwarded_header_count", len(info.headers)). | |
| 273 | + | Msg("cef: GetResourceHandler returning transcoding resource handler") | |
| 185 | 274 | return purecef.NewResourceHandler(&transcodingResourceHandler{ | |
| 186 | 275 | transcoder: h.transcoder, | |
| 187 | - | sourceURL: url, | |
| 276 | + | sourceURL: sourceURL, | |
| 188 | 277 | headers: info.headers, | |
| 189 | 278 | ctx: ctx, | |
| 190 | 279 | cancel: cancel, | |
| 280 | + | logf: h.logf, | |
| 191 | 281 | }) | |
| 192 | 282 | } | |
| 193 | 283 | ||
| @@ -213,3 +303,57 @@ func (h *transcodingRequestHandler) OnResourceLoadComplete(_ purecef.Browser, _ | |||
| 213 | 303 | ||
| 214 | 304 | func (h *transcodingRequestHandler) OnProtocolExecution(_ purecef.Browser, _ purecef.Frame, _ purecef.Request, _ unsafe.Pointer) { | |
| 215 | 305 | } | |
| 306 | + | ||
| 307 | + | func isLikelyMediaResponse(rawURL, mimeType string) bool { | |
| 308 | + | return strings.HasPrefix(mimeType, "video/") || | |
| 309 | + | strings.HasPrefix(mimeType, "audio/") || | |
| 310 | + | transcoder.IsStreamingManifestMIME(mimeType) || | |
| 311 | + | transcoder.IsStreamingManifestURL(rawURL) | |
| 312 | + | } | |
| 313 | + | ||
| 314 | + | func buildRequestInfo(request purecef.Request) requestInfo { | |
| 315 | + | headers := make(map[string]string) | |
| 316 | + | if request == nil { | |
| 317 | + | return requestInfo{headers: headers} | |
| 318 | + | } | |
| 319 | + | if cookie := request.GetHeaderByName("Cookie"); cookie != "" { | |
| 320 | + | headers["Cookie"] = cookie | |
| 321 | + | } | |
| 322 | + | if auth := request.GetHeaderByName("Authorization"); auth != "" { | |
| 323 | + | headers["Authorization"] = auth | |
| 324 | + | } | |
| 325 | + | if referer := request.GetHeaderByName("Referer"); referer != "" { | |
| 326 | + | headers["Referer"] = referer | |
| 327 | + | } | |
| 328 | + | if userAgent := request.GetHeaderByName("User-Agent"); userAgent != "" { | |
| 329 | + | headers["User-Agent"] = userAgent | |
| 330 | + | } | |
| 331 | + | if origin := request.GetHeaderByName("Origin"); origin != "" { | |
| 332 | + | headers["Origin"] = origin | |
| 333 | + | } | |
| 334 | + | return requestInfo{headers: headers} | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | func (h *transcodingRequestHandler) logger() *zerolog.Logger { | |
| 338 | + | logger := h.logf() | |
| 339 | + | return &logger | |
| 340 | + | } | |
| 341 | + | ||
| 342 | + | func (h *transcodingRequestHandler) logf() zerolog.Logger { | |
| 343 | + | ctx := context.Background() | |
| 344 | + | if h != nil && h.ctxf != nil { | |
| 345 | + | if provided := h.ctxf(); provided != nil { | |
| 346 | + | ctx = provided | |
| 347 | + | } | |
| 348 | + | } | |
| 349 | + | return logging.FromContext(ctx).With().Str("component", "cef-transcoding").Logger() | |
| 350 | + | } | |
| 351 | + | ||
| 352 | + | func (rh *transcodingResourceHandler) logger() *zerolog.Logger { | |
| 353 | + | if rh != nil && rh.logf != nil { | |
| 354 | + | logger := rh.logf().With().Str("source_url", logging.TruncateURL(rh.sourceURL, 240)).Logger() | |
| 355 | + | return &logger | |
| 356 | + | } | |
| 357 | + | logger := logging.FromContext(context.Background()).With().Str("component", "cef-transcoding").Logger() | |
| 358 | + | return &logger | |
| 359 | + | } | |
| @@ -2,6 +2,8 @@ package cef | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "io" | |||
| 6 | + | "strings" | |||
| 5 | "sync" | 7 | "sync" | ||
| 6 | "time" | 8 | "time" | ||
| 7 | "unsafe" | 9 | "unsafe" | ||
| @@ -10,6 +12,8 @@ import ( | |||||
| 10 | 12 | ||||
| 11 | "github.com/bnema/dumber/internal/application/port" | 13 | "github.com/bnema/dumber/internal/application/port" | ||
| 12 | "github.com/bnema/dumber/internal/infrastructure/transcoder" | 14 | "github.com/bnema/dumber/internal/infrastructure/transcoder" | ||
| 15 | + | "github.com/bnema/dumber/internal/logging" | |||
| 16 | + | "github.com/rs/zerolog" | |||
| 13 | ) | 17 | ) | ||
| 14 | 18 | ||||
| 15 | // --------------------------------------------------------------------------- | 19 | // --------------------------------------------------------------------------- | ||
| @@ -32,6 +36,8 @@ type transcodingResourceHandler struct { | |||||
| 32 | session port.TranscodeSession | 36 | session port.TranscodeSession | ||
| 33 | ctx context.Context | 37 | ctx context.Context | ||
| 34 | cancel context.CancelFunc | 38 | cancel context.CancelFunc | ||
| 39 | + | logf func() zerolog.Logger | |||
| 40 | + | totalBytes int64 | |||
| 35 | } | 41 | } | ||
| 36 | 42 | ||||
| 37 | // Open starts the transcode session asynchronously. CEF will call | 43 | // Open starts the transcode session asynchronously. CEF will call | ||
| @@ -40,14 +46,28 @@ func (rh *transcodingResourceHandler) Open(_ purecef.Request, handleRequest unsa | |||||
| 40 | // Signal async handling: set handleRequest = 0. | 46 | // Signal async handling: set handleRequest = 0. | ||
| 41 | *(*int32)(handleRequest) = 0 | 47 | *(*int32)(handleRequest) = 0 | ||
| 42 | 48 | ||||
| 49 | + | log := rh.logger() | |||
| 50 | + | log.Info(). | |||
| 51 | + | Str("source_url", logging.TruncateURL(rh.sourceURL, 240)). | |||
| 52 | + | Int("forwarded_header_count", len(rh.headers)). | |||
| 53 | + | Msg("cef: starting transcoding resource stream") | |||
| 54 | + | ||||
| 43 | go func() { | 55 | go func() { | ||
| 44 | session, err := rh.transcoder.Start(rh.ctx, rh.sourceURL, rh.headers) | 56 | session, err := rh.transcoder.Start(rh.ctx, rh.sourceURL, rh.headers) | ||
| 45 | if err != nil { | 57 | if err != nil { | ||
| 58 | + | log.Error(). | |||
| 59 | + | Err(err). | |||
| 60 | + | Str("source_url", logging.TruncateURL(rh.sourceURL, 240)). | |||
| 61 | + | Msg("cef: failed to start transcoding resource stream") | |||
| 46 | // Let CEF know we failed — Cancel will be called. | 62 | // Let CEF know we failed — Cancel will be called. | ||
| 47 | rh.cancel() | 63 | rh.cancel() | ||
| 48 | return | 64 | return | ||
| 49 | } | 65 | } | ||
| 50 | rh.session = session | 66 | rh.session = session | ||
| 67 | + | log.Info(). | |||
| 68 | + | Str("source_url", logging.TruncateURL(rh.sourceURL, 240)). | |||
| 69 | + | Str("content_type", session.ContentType()). | |||
| 70 | + | Msg("cef: transcoding resource stream ready") | |||
| 51 | callback.Cont() | 71 | callback.Cont() | ||
| 52 | }() | 72 | }() | ||
| 53 | 73 | ||||
| @@ -60,6 +80,8 @@ func (rh *transcodingResourceHandler) GetResponseHeaders(response purecef.Respon | |||||
| 60 | response.SetStatusText("OK") | 80 | response.SetStatusText("OK") | ||
| 61 | response.SetMimeType("video/webm") | 81 | response.SetMimeType("video/webm") | ||
| 62 | response.SetHeaderByName("Accept-Ranges", "none", 1) | 82 | response.SetHeaderByName("Accept-Ranges", "none", 1) | ||
| 83 | + | response.SetHeaderByName("Access-Control-Allow-Origin", "*", 1) | |||
| 84 | + | response.SetHeaderByName("Cache-Control", "no-store", 1) | |||
| 63 | // Streaming — unknown length. | 85 | // Streaming — unknown length. | ||
| 64 | *(*int64)(responseLength) = -1 | 86 | *(*int64)(responseLength) = -1 | ||
| 65 | } | 87 | } | ||
| @@ -76,10 +98,29 @@ func (rh *transcodingResourceHandler) Read( | |||||
| 76 | dst := unsafe.Slice((*byte)(dataOut), int(bytesToRead)) | 98 | dst := unsafe.Slice((*byte)(dataOut), int(bytesToRead)) | ||
| 77 | n, err := rh.session.Read(dst) | 99 | n, err := rh.session.Read(dst) | ||
| 78 | if n > 0 { | 100 | if n > 0 { | ||
| 101 | + | rh.totalBytes += int64(n) | |||
| 102 | + | if rh.totalBytes == int64(n) { | |||
| 103 | + | rh.logger().Info(). | |||
| 104 | + | Str("source_url", logging.TruncateURL(rh.sourceURL, 240)). | |||
| 105 | + | Int("first_chunk_bytes", n). | |||
| 106 | + | Msg("cef: transcoding resource stream produced first bytes") | |||
| 107 | + | } | |||
| 79 | *(*int32)(bytesRead) = int32(n) | 108 | *(*int32)(bytesRead) = int32(n) | ||
| 80 | return 1 | 109 | return 1 | ||
| 81 | } | 110 | } | ||
| 82 | if err != nil { | 111 | if err != nil { | ||
| 112 | + | log := rh.logger().With(). | |||
| 113 | + | Str("source_url", logging.TruncateURL(rh.sourceURL, 240)). | |||
| 114 | + | Int64("total_bytes", rh.totalBytes). | |||
| 115 | + | Logger() | |||
| 116 | + | switch { | |||
| 117 | + | case err == io.EOF && rh.totalBytes == 0: | |||
| 118 | + | log.Warn().Msg("cef: transcoding resource stream ended before producing data") | |||
| 119 | + | case err == io.EOF: | |||
| 120 | + | log.Debug().Msg("cef: transcoding resource stream reached EOF") | |||
| 121 | + | default: | |||
| 122 | + | log.Error().Err(err).Msg("cef: transcoding resource stream read failed") | |||
| 123 | + | } | |||
| 83 | return 0 // EOF or error | 124 | return 0 // EOF or error | ||
| 84 | } | 125 | } | ||
| 85 | return 0 | 126 | return 0 | ||
| @@ -87,6 +128,10 @@ func (rh *transcodingResourceHandler) Read( | |||||
| 87 | 128 | ||||
| 88 | // Cancel aborts the transcode session and releases resources. | 129 | // Cancel aborts the transcode session and releases resources. | ||
| 89 | func (rh *transcodingResourceHandler) Cancel() { | 130 | func (rh *transcodingResourceHandler) Cancel() { | ||
| 131 | + | rh.logger().Debug(). | |||
| 132 | + | Str("source_url", logging.TruncateURL(rh.sourceURL, 240)). | |||
| 133 | + | Int64("total_bytes", rh.totalBytes). | |||
| 134 | + | Msg("cef: transcoding resource stream cancelled") | |||
| 90 | if rh.session != nil { | 135 | if rh.session != nil { | ||
| 91 | rh.session.Close() | 136 | rh.session.Close() | ||
| 92 | } | 137 | } | ||
| @@ -112,12 +157,14 @@ func (rh *transcodingResourceHandler) Skip(_ int64, _ unsafe.Pointer, _ purecef. | |||||
| 112 | 157 | ||||
| 113 | // --------------------------------------------------------------------------- | 158 | // --------------------------------------------------------------------------- | ||
| 114 | // transcodingRequestHandler — ResourceRequestHandler that intercepts | 159 | // transcodingRequestHandler — ResourceRequestHandler that intercepts | ||
| 115 | - | // proprietary video responses and restarts them through the transcoder | 160 | + | // proprietary video responses and streaming manifests, then restarts them |
| 161 | + | // through the transcoder | |||
| 116 | // --------------------------------------------------------------------------- | 162 | // --------------------------------------------------------------------------- | ||
| 117 | 163 | ||||
| 118 | type transcodingRequestHandler struct { | 164 | type transcodingRequestHandler struct { | ||
| 119 | transcoder port.MediaTranscoder | 165 | transcoder port.MediaTranscoder | ||
| 120 | transcodableURLs sync.Map // url string -> requestInfo | 166 | transcodableURLs sync.Map // url string -> requestInfo | ||
| 167 | + | ctxf func() context.Context | |||
| 121 | } | 168 | } | ||
| 122 | 169 | ||||
| 123 | type requestInfo struct { | 170 | type requestInfo struct { | ||
| @@ -125,69 +172,112 @@ type requestInfo struct { | |||||
| 125 | } | 172 | } | ||
| 126 | 173 | ||||
| 127 | // newTranscodingRequestHandler creates a ResourceRequestHandler that detects | 174 | // newTranscodingRequestHandler creates a ResourceRequestHandler that detects | ||
| 128 | - | // proprietary video MIME types and restarts those requests through a GPU | 175 | + | // proprietary video containers and HLS/DASH manifest entrypoints, then |
| 129 | - | // transcoding ResourceHandler. | 176 | + | // restarts those requests through a GPU transcoding ResourceHandler. |
| 130 | - | func newTranscodingRequestHandler(tc port.MediaTranscoder) purecef.ResourceRequestHandler { | 177 | + | func newTranscodingRequestHandler(tc port.MediaTranscoder, ctxf func() context.Context) purecef.ResourceRequestHandler { |
| 131 | return purecef.NewResourceRequestHandler(&transcodingRequestHandler{ | 178 | return purecef.NewResourceRequestHandler(&transcodingRequestHandler{ | ||
| 132 | transcoder: tc, | 179 | transcoder: tc, | ||
| 180 | + | ctxf: ctxf, | |||
| 133 | }) | 181 | }) | ||
| 134 | } | 182 | } | ||
| 135 | 183 | ||||
| 136 | - | // OnResourceResponse inspects the MIME type of every response. If it matches | 184 | + | // OnResourceResponse inspects the MIME type and URL of every response. If it |
| 137 | - | // a proprietary video format (H.264, HEVC, etc.), it stores the request info | 185 | + | // matches a proprietary video format or a streaming manifest entrypoint, it |
| 138 | - | // and returns 1 to restart the request — at which point GetResourceHandler | 186 | + | // stores the request info and returns 1 to restart the request — at which |
| 139 | - | // will provide the transcoding handler. | 187 | + | // point GetResourceHandler will provide the transcoding handler. |
| 140 | func (h *transcodingRequestHandler) OnResourceResponse(_ purecef.Browser, _ purecef.Frame, request purecef.Request, response purecef.Response) int32 { | 188 | func (h *transcodingRequestHandler) OnResourceResponse(_ purecef.Browser, _ purecef.Frame, request purecef.Request, response purecef.Response) int32 { | ||
| 141 | - | if response == nil { | 189 | + | if response == nil || request == nil { |
| 142 | return 0 | 190 | return 0 | ||
| 143 | } | 191 | } | ||
| 144 | mimeType := response.GetMimeType() | 192 | mimeType := response.GetMimeType() | ||
| 145 | - | if !transcoder.IsProprietaryVideoMIME(mimeType) { | 193 | + | url := request.GetURL() |
| 146 | - | return 0 | |||
| 147 | - | } | |||
| 148 | 194 | ||||
| 149 | - | // Extract relevant headers from the original request so the transcoder | 195 | + | log := h.logger() |
| 150 | - | // can authenticate with the source server. | 196 | + | isProprietary := transcoder.IsProprietaryVideoMIME(mimeType) |
| 151 | - | headers := make(map[string]string) | 197 | + | isAlreadyOpen := transcoder.IsOpenVideoMIME(mimeType) |
| 152 | - | if cookie := request.GetHeaderByName("Cookie"); cookie != "" { | 198 | + | isManifest := !isAlreadyOpen && (transcoder.IsStreamingManifestMIME(mimeType) || transcoder.IsStreamingManifestURL(url)) |
| 153 | - | headers["Cookie"] = cookie | 199 | + | if isLikelyMediaResponse(url, mimeType) { |
| 200 | + | log.Debug(). | |||
| 201 | + | Str("url", logging.TruncateURL(url, 240)). | |||
| 202 | + | Str("mime_type", mimeType). | |||
| 203 | + | Bool("proprietary_match", isProprietary). | |||
| 204 | + | Bool("already_open", isAlreadyOpen). | |||
| 205 | + | Bool("manifest_match", isManifest). | |||
| 206 | + | Msg("cef: OnResourceResponse observed media response") | |||
| 154 | } | 207 | } | ||
| 155 | - | if auth := request.GetHeaderByName("Authorization"); auth != "" { | 208 | + | |
| 156 | - | headers["Authorization"] = auth | 209 | + | if isAlreadyOpen { |
| 210 | + | return 0 | |||
| 157 | } | 211 | } | ||
| 158 | - | if referer := request.GetHeaderByName("Referer"); referer != "" { | 212 | + | |
| 159 | - | headers["Referer"] = referer | 213 | + | if !isProprietary && !isManifest { |
| 214 | + | return 0 | |||
| 160 | } | 215 | } | ||
| 161 | 216 | ||||
| 162 | - | url := request.GetURL() | 217 | + | info := buildRequestInfo(request) |
| 163 | - | h.transcodableURLs.Store(url, requestInfo{headers: headers}) | 218 | + | h.transcodableURLs.Store(url, info) |
| 219 | + | reason := "proprietary-container" | |||
| 220 | + | if isManifest { | |||
| 221 | + | reason = "streaming-manifest" | |||
| 222 | + | } | |||
| 223 | + | log.Info(). | |||
| 224 | + | Str("url", logging.TruncateURL(url, 240)). | |||
| 225 | + | Str("mime_type", mimeType). | |||
| 226 | + | Str("transcode_reason", reason). | |||
| 227 | + | Int("forwarded_header_count", len(info.headers)). | |||
| 228 | + | Msg("cef: media response marked for transcoding") | |||
| 164 | 229 | ||||
| 165 | return 1 // restart request | 230 | return 1 // restart request | ||
| 166 | } | 231 | } | ||
| 167 | 232 | ||||
| 168 | // GetResourceHandler returns a transcoding ResourceHandler for URLs that were | 233 | // GetResourceHandler returns a transcoding ResourceHandler for URLs that were | ||
| 169 | - | // previously identified as proprietary video by OnResourceResponse. | 234 | + | // either identified eagerly by URL heuristics or marked by OnResourceResponse. |
| 170 | func (h *transcodingRequestHandler) GetResourceHandler(_ purecef.Browser, _ purecef.Frame, request purecef.Request) purecef.ResourceHandler { | 235 | func (h *transcodingRequestHandler) GetResourceHandler(_ purecef.Browser, _ purecef.Frame, request purecef.Request) purecef.ResourceHandler { | ||
| 171 | if request == nil { | 236 | if request == nil { | ||
| 172 | return nil | 237 | return nil | ||
| 173 | } | 238 | } | ||
| 174 | url := request.GetURL() | 239 | url := request.GetURL() | ||
| 175 | val, ok := h.transcodableURLs.LoadAndDelete(url) | 240 | val, ok := h.transcodableURLs.LoadAndDelete(url) | ||
| 176 | - | if !ok { | 241 | + | info := requestInfo{} |
| 177 | - | return nil | 242 | + | eager := false |
| 178 | - | } | 243 | + | sourceURL := url |
| 179 | - | info, ok := val.(requestInfo) | 244 | + | if ok { |
| 180 | - | if !ok { | 245 | + | cached, typeOK := val.(requestInfo) |
| 246 | + | if !typeOK { | |||
| 247 | + | return nil | |||
| 248 | + | } | |||
| 249 | + | info = cached | |||
| 250 | + | } else if syntheticSourceURL, referer, origin, synthetic := transcoder.ParseSyntheticTranscodeURL(url); synthetic { | |||
| 251 | + | eager = true | |||
| 252 | + | sourceURL = syntheticSourceURL | |||
| 253 | + | info = buildRequestInfo(request) | |||
| 254 | + | if referer != "" && info.headers["Referer"] == "" { | |||
| 255 | + | info.headers["Referer"] = referer | |||
| 256 | + | } | |||
| 257 | + | if origin != "" && info.headers["Origin"] == "" { | |||
| 258 | + | info.headers["Origin"] = origin | |||
| 259 | + | } | |||
| 260 | + | } else if transcoder.IsEagerTranscodeURL(url) { | |||
| 261 | + | eager = true | |||
| 262 | + | info = buildRequestInfo(request) | |||
| 263 | + | } else { | |||
| 181 | return nil | 264 | return nil | ||
| 182 | } | 265 | } | ||
| 183 | 266 | ||||
| 184 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) | 267 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) | ||
| 268 | + | h.logger().Info(). | |||
| 269 | + | Str("url", logging.TruncateURL(url, 240)). | |||
| 270 | + | Str("source_url", logging.TruncateURL(sourceURL, 240)). | |||
| 271 | + | Bool("eager_match", eager). | |||
| 272 | + | Int("forwarded_header_count", len(info.headers)). | |||
| 273 | + | Msg("cef: GetResourceHandler returning transcoding resource handler") | |||
| 185 | return purecef.NewResourceHandler(&transcodingResourceHandler{ | 274 | return purecef.NewResourceHandler(&transcodingResourceHandler{ | ||
| 186 | transcoder: h.transcoder, | 275 | transcoder: h.transcoder, | ||
| 187 | - | sourceURL: url, | 276 | + | sourceURL: sourceURL, |
| 188 | headers: info.headers, | 277 | headers: info.headers, | ||
| 189 | ctx: ctx, | 278 | ctx: ctx, | ||
| 190 | cancel: cancel, | 279 | cancel: cancel, | ||
| 280 | + | logf: h.logf, | |||
| 191 | }) | 281 | }) | ||
| 192 | } | 282 | } | ||
| 193 | 283 | ||||
| @@ -213,3 +303,57 @@ func (h *transcodingRequestHandler) OnResourceLoadComplete(_ purecef.Browser, _ | |||||
| 213 | 303 | ||||
| 214 | func (h *transcodingRequestHandler) OnProtocolExecution(_ purecef.Browser, _ purecef.Frame, _ purecef.Request, _ unsafe.Pointer) { | 304 | func (h *transcodingRequestHandler) OnProtocolExecution(_ purecef.Browser, _ purecef.Frame, _ purecef.Request, _ unsafe.Pointer) { | ||
| 215 | } | 305 | } | ||
| 306 | + | ||||
| 307 | + | func isLikelyMediaResponse(rawURL, mimeType string) bool { | |||
| 308 | + | return strings.HasPrefix(mimeType, "video/") || | |||
| 309 | + | strings.HasPrefix(mimeType, "audio/") || | |||
| 310 | + | transcoder.IsStreamingManifestMIME(mimeType) || | |||
| 311 | + | transcoder.IsStreamingManifestURL(rawURL) | |||
| 312 | + | } | |||
| 313 | + | ||||
| 314 | + | func buildRequestInfo(request purecef.Request) requestInfo { | |||
| 315 | + | headers := make(map[string]string) | |||
| 316 | + | if request == nil { | |||
| 317 | + | return requestInfo{headers: headers} | |||
| 318 | + | } | |||
| 319 | + | if cookie := request.GetHeaderByName("Cookie"); cookie != "" { | |||
| 320 | + | headers["Cookie"] = cookie | |||
| 321 | + | } | |||
| 322 | + | if auth := request.GetHeaderByName("Authorization"); auth != "" { | |||
| 323 | + | headers["Authorization"] = auth | |||
| 324 | + | } | |||
| 325 | + | if referer := request.GetHeaderByName("Referer"); referer != "" { | |||
| 326 | + | headers["Referer"] = referer | |||
| 327 | + | } | |||
| 328 | + | if userAgent := request.GetHeaderByName("User-Agent"); userAgent != "" { | |||
| 329 | + | headers["User-Agent"] = userAgent | |||
| 330 | + | } | |||
| 331 | + | if origin := request.GetHeaderByName("Origin"); origin != "" { | |||
| 332 | + | headers["Origin"] = origin | |||
| 333 | + | } | |||
| 334 | + | return requestInfo{headers: headers} | |||
| 335 | + | } | |||
| 336 | + | ||||
| 337 | + | func (h *transcodingRequestHandler) logger() *zerolog.Logger { | |||
| 338 | + | logger := h.logf() | |||
| 339 | + | return &logger | |||
| 340 | + | } | |||
| 341 | + | ||||
| 342 | + | func (h *transcodingRequestHandler) logf() zerolog.Logger { | |||
| 343 | + | ctx := context.Background() | |||
| 344 | + | if h != nil && h.ctxf != nil { | |||
| 345 | + | if provided := h.ctxf(); provided != nil { | |||
| 346 | + | ctx = provided | |||
| 347 | + | } | |||
| 348 | + | } | |||
| 349 | + | return logging.FromContext(ctx).With().Str("component", "cef-transcoding").Logger() | |||
| 350 | + | } | |||
| 351 | + | ||||
| 352 | + | func (rh *transcodingResourceHandler) logger() *zerolog.Logger { | |||
| 353 | + | if rh != nil && rh.logf != nil { | |||
| 354 | + | logger := rh.logf().With().Str("source_url", logging.TruncateURL(rh.sourceURL, 240)).Logger() | |||
| 355 | + | return &logger | |||
| 356 | + | } | |||
| 357 | + | logger := logging.FromContext(context.Background()).With().Str("component", "cef-transcoding").Logger() | |||
| 358 | + | return &logger | |||
| 359 | + | } | |||
M internal/infrastructure/cef/webview_handlers.go +29 -3
| @@ -1,9 +1,12 @@ | |||
| 1 | 1 | package cef | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "strings" | |
| 5 | + | ||
| 4 | 6 | purecef "github.com/bnema/purego-cef/cef" | |
| 5 | 7 | ||
| 6 | 8 | "github.com/bnema/dumber/internal/application/port" | |
| 9 | + | "github.com/bnema/dumber/internal/infrastructure/transcoder" | |
| 7 | 10 | "github.com/bnema/dumber/internal/logging" | |
| 8 | 11 | ) | |
| 9 | 12 | ||
| @@ -312,7 +315,22 @@ func (h *handlerSet) OnStatusMessage(_ purecef.Browser, value string) { | |||
| 312 | 315 | } | |
| 313 | 316 | } | |
| 314 | 317 | ||
| 315 | - | func (h *handlerSet) OnConsoleMessage(_ purecef.Browser, _ purecef.LogSeverity, _, _ string, _ int32) int32 { | |
| 318 | + | func (h *handlerSet) OnConsoleMessage(_ purecef.Browser, level purecef.LogSeverity, message, source string, line int32) int32 { | |
| 319 | + | if h.wv != nil && h.wv.ctx != nil && (strings.Contains(message, "[VIDEO-DIAG]") || strings.Contains(message, "[REDDIT-VIDEO-PATCH]")) { | |
| 320 | + | log := logging.FromContext(h.wv.ctx).With(). | |
| 321 | + | Str("component", "cef-console"). | |
| 322 | + | Str("source", source). | |
| 323 | + | Int32("line", line). | |
| 324 | + | Logger() | |
| 325 | + | switch level { | |
| 326 | + | case purecef.LogSeverityLogseverityError, purecef.LogSeverityLogseverityFatal: | |
| 327 | + | log.Error().Str("message", message).Msg("cef: console message") | |
| 328 | + | case purecef.LogSeverityLogseverityWarning: | |
| 329 | + | log.Warn().Str("message", message).Msg("cef: console message") | |
| 330 | + | default: | |
| 331 | + | log.Info().Str("message", message).Msg("cef: console message") | |
| 332 | + | } | |
| 333 | + | } | |
| 316 | 334 | return 0 | |
| 317 | 335 | } | |
| 318 | 336 | ||
| @@ -557,9 +575,17 @@ func (h *handlerSet) OnOpenUrlfromTab(_ purecef.Browser, _ purecef.Frame, _ stri | |||
| 557 | 575 | } | |
| 558 | 576 | ||
| 559 | 577 | func (h *handlerSet) GetResourceRequestHandler( | |
| 560 | - | _ purecef.Browser, _ purecef.Frame, _ purecef.Request, | |
| 561 | - | _, _ int32, _ string, _ *int32, | |
| 578 | + | _ purecef.Browser, _ purecef.Frame, request purecef.Request, | |
| 579 | + | _, _ int32, _ string, disableDefaultHandling *int32, | |
| 562 | 580 | ) purecef.ResourceRequestHandler { | |
| 581 | + | if h.transcodingHandler != nil && request != nil && disableDefaultHandling != nil && transcoder.IsEagerTranscodeURL(request.GetURL()) { | |
| 582 | + | *disableDefaultHandling = 1 | |
| 583 | + | if h.wv != nil && h.wv.ctx != nil { | |
| 584 | + | logging.FromContext(h.wv.ctx).Info(). | |
| 585 | + | Str("url", logging.TruncateURL(request.GetURL(), 240)). | |
| 586 | + | Msg("cef: disabled default handling for eager transcode candidate") | |
| 587 | + | } | |
| 588 | + | } | |
| 563 | 589 | if h.transcodingHandler != nil { | |
| 564 | 590 | return h.transcodingHandler | |
| 565 | 591 | } | |
| @@ -1,9 +1,12 @@ | |||||
| 1 | package cef | 1 | package cef | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "strings" | |||
| 5 | + | ||||
| 4 | purecef "github.com/bnema/purego-cef/cef" | 6 | purecef "github.com/bnema/purego-cef/cef" | ||
| 5 | 7 | ||||
| 6 | "github.com/bnema/dumber/internal/application/port" | 8 | "github.com/bnema/dumber/internal/application/port" | ||
| 9 | + | "github.com/bnema/dumber/internal/infrastructure/transcoder" | |||
| 7 | "github.com/bnema/dumber/internal/logging" | 10 | "github.com/bnema/dumber/internal/logging" | ||
| 8 | ) | 11 | ) | ||
| 9 | 12 | ||||
| @@ -312,7 +315,22 @@ func (h *handlerSet) OnStatusMessage(_ purecef.Browser, value string) { | |||||
| 312 | } | 315 | } | ||
| 313 | } | 316 | } | ||
| 314 | 317 | ||||
| 315 | - | func (h *handlerSet) OnConsoleMessage(_ purecef.Browser, _ purecef.LogSeverity, _, _ string, _ int32) int32 { | 318 | + | func (h *handlerSet) OnConsoleMessage(_ purecef.Browser, level purecef.LogSeverity, message, source string, line int32) int32 { |
| 319 | + | if h.wv != nil && h.wv.ctx != nil && (strings.Contains(message, "[VIDEO-DIAG]") || strings.Contains(message, "[REDDIT-VIDEO-PATCH]")) { | |||
| 320 | + | log := logging.FromContext(h.wv.ctx).With(). | |||
| 321 | + | Str("component", "cef-console"). | |||
| 322 | + | Str("source", source). | |||
| 323 | + | Int32("line", line). | |||
| 324 | + | Logger() | |||
| 325 | + | switch level { | |||
| 326 | + | case purecef.LogSeverityLogseverityError, purecef.LogSeverityLogseverityFatal: | |||
| 327 | + | log.Error().Str("message", message).Msg("cef: console message") | |||
| 328 | + | case purecef.LogSeverityLogseverityWarning: | |||
| 329 | + | log.Warn().Str("message", message).Msg("cef: console message") | |||
| 330 | + | default: | |||
| 331 | + | log.Info().Str("message", message).Msg("cef: console message") | |||
| 332 | + | } | |||
| 333 | + | } | |||
| 316 | return 0 | 334 | return 0 | ||
| 317 | } | 335 | } | ||
| 318 | 336 | ||||
| @@ -557,9 +575,17 @@ func (h *handlerSet) OnOpenUrlfromTab(_ purecef.Browser, _ purecef.Frame, _ stri | |||||
| 557 | } | 575 | } | ||
| 558 | 576 | ||||
| 559 | func (h *handlerSet) GetResourceRequestHandler( | 577 | func (h *handlerSet) GetResourceRequestHandler( | ||
| 560 | - | _ purecef.Browser, _ purecef.Frame, _ purecef.Request, | 578 | + | _ purecef.Browser, _ purecef.Frame, request purecef.Request, |
| 561 | - | _, _ int32, _ string, _ *int32, | 579 | + | _, _ int32, _ string, disableDefaultHandling *int32, |
| 562 | ) purecef.ResourceRequestHandler { | 580 | ) purecef.ResourceRequestHandler { | ||
| 581 | + | if h.transcodingHandler != nil && request != nil && disableDefaultHandling != nil && transcoder.IsEagerTranscodeURL(request.GetURL()) { | |||
| 582 | + | *disableDefaultHandling = 1 | |||
| 583 | + | if h.wv != nil && h.wv.ctx != nil { | |||
| 584 | + | logging.FromContext(h.wv.ctx).Info(). | |||
| 585 | + | Str("url", logging.TruncateURL(request.GetURL(), 240)). | |||
| 586 | + | Msg("cef: disabled default handling for eager transcode candidate") | |||
| 587 | + | } | |||
| 588 | + | } | |||
| 563 | if h.transcodingHandler != nil { | 589 | if h.transcodingHandler != nil { | ||
| 564 | return h.transcodingHandler | 590 | return h.transcodingHandler | ||
| 565 | } | 591 | } | ||
M internal/infrastructure/config/defaults.go +12 -0
| @@ -70,6 +70,12 @@ const ( | |||
| 70 | 70 | defaultCEFWindowlessFrameRate = 60 // OSR frame rate for CEF | |
| 71 | 71 | defaultCEFManualPumpIntervalMs = 10 // CEF manual message pump interval | |
| 72 | 72 | ||
| 73 | + | // Transcoding defaults | |
| 74 | + | defaultTranscodingEnabled = true | |
| 75 | + | defaultTranscodingHWAccel = "auto" | |
| 76 | + | defaultTranscodingMaxConcurrent = 3 | |
| 77 | + | defaultTranscodingQuality = "medium" | |
| 78 | + | ||
| 73 | 79 | // Skia threading defaults (0 = unset, -1 = unset for GPU threads) | |
| 74 | 80 | defaultSkiaCPUPaintingThreads = 0 | |
| 75 | 81 | defaultSkiaGPUPaintingThreads = -1 // -1 means unset; 0 would disable GPU tile painting | |
| @@ -315,6 +321,12 @@ func DefaultConfig() *Config { | |||
| 315 | 321 | Downloads: DownloadsConfig{ | |
| 316 | 322 | Path: "", // Empty = use XDG_DOWNLOAD_DIR or ~/Downloads | |
| 317 | 323 | }, | |
| 324 | + | Transcoding: TranscodingConfig{ | |
| 325 | + | Enabled: defaultTranscodingEnabled, | |
| 326 | + | HWAccel: defaultTranscodingHWAccel, | |
| 327 | + | MaxConcurrent: defaultTranscodingMaxConcurrent, | |
| 328 | + | Quality: defaultTranscodingQuality, | |
| 329 | + | }, | |
| 318 | 330 | } | |
| 319 | 331 | } | |
| 320 | 332 | ||
| @@ -70,6 +70,12 @@ const ( | |||||
| 70 | defaultCEFWindowlessFrameRate = 60 // OSR frame rate for CEF | 70 | defaultCEFWindowlessFrameRate = 60 // OSR frame rate for CEF | ||
| 71 | defaultCEFManualPumpIntervalMs = 10 // CEF manual message pump interval | 71 | defaultCEFManualPumpIntervalMs = 10 // CEF manual message pump interval | ||
| 72 | 72 | ||||
| 73 | + | // Transcoding defaults | |||
| 74 | + | defaultTranscodingEnabled = true | |||
| 75 | + | defaultTranscodingHWAccel = "auto" | |||
| 76 | + | defaultTranscodingMaxConcurrent = 3 | |||
| 77 | + | defaultTranscodingQuality = "medium" | |||
| 78 | + | ||||
| 73 | // Skia threading defaults (0 = unset, -1 = unset for GPU threads) | 79 | // Skia threading defaults (0 = unset, -1 = unset for GPU threads) | ||
| 74 | defaultSkiaCPUPaintingThreads = 0 | 80 | defaultSkiaCPUPaintingThreads = 0 | ||
| 75 | defaultSkiaGPUPaintingThreads = -1 // -1 means unset; 0 would disable GPU tile painting | 81 | defaultSkiaGPUPaintingThreads = -1 // -1 means unset; 0 would disable GPU tile painting | ||
| @@ -315,6 +321,12 @@ func DefaultConfig() *Config { | |||||
| 315 | Downloads: DownloadsConfig{ | 321 | Downloads: DownloadsConfig{ | ||
| 316 | Path: "", // Empty = use XDG_DOWNLOAD_DIR or ~/Downloads | 322 | Path: "", // Empty = use XDG_DOWNLOAD_DIR or ~/Downloads | ||
| 317 | }, | 323 | }, | ||
| 324 | + | Transcoding: TranscodingConfig{ | |||
| 325 | + | Enabled: defaultTranscodingEnabled, | |||
| 326 | + | HWAccel: defaultTranscodingHWAccel, | |||
| 327 | + | MaxConcurrent: defaultTranscodingMaxConcurrent, | |||
| 328 | + | Quality: defaultTranscodingQuality, | |||
| 329 | + | }, | |||
| 318 | } | 330 | } | ||
| 319 | } | 331 | } | ||
| 320 | 332 | ||||
M internal/infrastructure/config/defaults_test.go +4 -0
| @@ -11,6 +11,10 @@ func TestDefaultConfig_CoreDefaults(t *testing.T) { | |||
| 11 | 11 | assert.Equal(t, "info", cfg.Logging.Level) | |
| 12 | 12 | assert.False(t, cfg.Logging.CaptureGTKLogs) | |
| 13 | 13 | assert.False(t, cfg.Media.ShowDiagnosticsOnStartup) | |
| 14 | + | assert.True(t, cfg.Transcoding.Enabled) | |
| 15 | + | assert.Equal(t, "auto", cfg.Transcoding.HWAccel) | |
| 16 | + | assert.Equal(t, 3, cfg.Transcoding.MaxConcurrent) | |
| 17 | + | assert.Equal(t, "medium", cfg.Transcoding.Quality) | |
| 14 | 18 | ||
| 15 | 19 | // Engine defaults (replaces old Performance/Privacy sections) | |
| 16 | 20 | assert.Equal(t, "webkit", cfg.Engine.Type) | |
| @@ -11,6 +11,10 @@ func TestDefaultConfig_CoreDefaults(t *testing.T) { | |||||
| 11 | assert.Equal(t, "info", cfg.Logging.Level) | 11 | assert.Equal(t, "info", cfg.Logging.Level) | ||
| 12 | assert.False(t, cfg.Logging.CaptureGTKLogs) | 12 | assert.False(t, cfg.Logging.CaptureGTKLogs) | ||
| 13 | assert.False(t, cfg.Media.ShowDiagnosticsOnStartup) | 13 | assert.False(t, cfg.Media.ShowDiagnosticsOnStartup) | ||
| 14 | + | assert.True(t, cfg.Transcoding.Enabled) | |||
| 15 | + | assert.Equal(t, "auto", cfg.Transcoding.HWAccel) | |||
| 16 | + | assert.Equal(t, 3, cfg.Transcoding.MaxConcurrent) | |||
| 17 | + | assert.Equal(t, "medium", cfg.Transcoding.Quality) | |||
| 14 | 18 | ||||
| 15 | // Engine defaults (replaces old Performance/Privacy sections) | 19 | // Engine defaults (replaces old Performance/Privacy sections) | ||
| 16 | assert.Equal(t, "webkit", cfg.Engine.Type) | 20 | assert.Equal(t, "webkit", cfg.Engine.Type) | ||
M internal/infrastructure/config/loader.go +5 -5
| @@ -607,11 +607,11 @@ func (m *Manager) setEngineDefaults(defaults *Config) { | |||
| 607 | 607 | m.viper.SetDefault("engine.webkit.network_process_memory_conservative_threshold", wk.NetworkProcessMemoryConservativeThreshold) | |
| 608 | 608 | m.viper.SetDefault("engine.webkit.network_process_memory_strict_threshold", wk.NetworkProcessMemoryStrictThreshold) | |
| 609 | 609 | ||
| 610 | - | // Transcoding defaults. | |
| 611 | - | m.viper.SetDefault("transcoding.enabled", true) | |
| 612 | - | m.viper.SetDefault("transcoding.hwaccel", "auto") | |
| 613 | - | m.viper.SetDefault("transcoding.max_concurrent", 3) | |
| 614 | - | m.viper.SetDefault("transcoding.quality", "medium") | |
| 610 | + | tc := defaults.Transcoding | |
| 611 | + | m.viper.SetDefault("transcoding.enabled", tc.Enabled) | |
| 612 | + | m.viper.SetDefault("transcoding.hwaccel", tc.HWAccel) | |
| 613 | + | m.viper.SetDefault("transcoding.max_concurrent", tc.MaxConcurrent) | |
| 614 | + | m.viper.SetDefault("transcoding.quality", tc.Quality) | |
| 615 | 615 | } | |
| 616 | 616 | ||
| 617 | 617 | // New returns a new default configuration instance. | |
| @@ -607,11 +607,11 @@ func (m *Manager) setEngineDefaults(defaults *Config) { | |||||
| 607 | m.viper.SetDefault("engine.webkit.network_process_memory_conservative_threshold", wk.NetworkProcessMemoryConservativeThreshold) | 607 | m.viper.SetDefault("engine.webkit.network_process_memory_conservative_threshold", wk.NetworkProcessMemoryConservativeThreshold) | ||
| 608 | m.viper.SetDefault("engine.webkit.network_process_memory_strict_threshold", wk.NetworkProcessMemoryStrictThreshold) | 608 | m.viper.SetDefault("engine.webkit.network_process_memory_strict_threshold", wk.NetworkProcessMemoryStrictThreshold) | ||
| 609 | 609 | ||||
| 610 | - | // Transcoding defaults. | 610 | + | tc := defaults.Transcoding |
| 611 | - | m.viper.SetDefault("transcoding.enabled", true) | 611 | + | m.viper.SetDefault("transcoding.enabled", tc.Enabled) |
| 612 | - | m.viper.SetDefault("transcoding.hwaccel", "auto") | 612 | + | m.viper.SetDefault("transcoding.hwaccel", tc.HWAccel) |
| 613 | - | m.viper.SetDefault("transcoding.max_concurrent", 3) | 613 | + | m.viper.SetDefault("transcoding.max_concurrent", tc.MaxConcurrent) |
| 614 | - | m.viper.SetDefault("transcoding.quality", "medium") | 614 | + | m.viper.SetDefault("transcoding.quality", tc.Quality) |
| 615 | } | 615 | } | ||
| 616 | 616 | ||||
| 617 | // New returns a new default configuration instance. | 617 | // New returns a new default configuration instance. | ||
M internal/infrastructure/config/loader_test.go +40 -0
| @@ -1,10 +1,13 @@ | |||
| 1 | 1 | package config | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "os" | |
| 5 | + | "path/filepath" | |
| 4 | 6 | "testing" | |
| 5 | 7 | ||
| 6 | 8 | "github.com/spf13/viper" | |
| 7 | 9 | "github.com/stretchr/testify/assert" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 8 | 11 | ) | |
| 9 | 12 | ||
| 10 | 13 | func TestSetEngineDefaults(t *testing.T) { | |
| @@ -24,6 +27,12 @@ func TestSetEngineDefaults(t *testing.T) { | |||
| 24 | 27 | assert.Equal(t, "auto", mgr.viper.GetString("engine.webkit.gl_rendering_mode")) | |
| 25 | 28 | assert.False(t, mgr.viper.GetBool("engine.webkit.disable_dmabuf_renderer")) | |
| 26 | 29 | ||
| 30 | + | // Transcoding defaults | |
| 31 | + | assert.True(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 | + | ||
| 27 | 36 | // Old sections should NOT be registered as defaults | |
| 28 | 37 | assert.Empty(t, mgr.viper.GetString("privacy.cookie_policy")) | |
| 29 | 38 | assert.Empty(t, mgr.viper.GetString("rendering.mode")) | |
| @@ -39,3 +48,34 @@ func TestNormalizeConfig_EngineCookiePolicy(t *testing.T) { | |||
| 39 | 48 | ||
| 40 | 49 | assert.Equal(t, CookiePolicyAlways, cfg.Engine.CookiePolicy) | |
| 41 | 50 | } | |
| 51 | + | ||
| 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 | + | ||
| 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.True(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 | + | } | |
| @@ -1,10 +1,13 @@ | |||||
| 1 | package config | 1 | package config | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "os" | |||
| 5 | + | "path/filepath" | |||
| 4 | "testing" | 6 | "testing" | ||
| 5 | 7 | ||||
| 6 | "github.com/spf13/viper" | 8 | "github.com/spf13/viper" | ||
| 7 | "github.com/stretchr/testify/assert" | 9 | "github.com/stretchr/testify/assert" | ||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 8 | ) | 11 | ) | ||
| 9 | 12 | ||||
| 10 | func TestSetEngineDefaults(t *testing.T) { | 13 | func TestSetEngineDefaults(t *testing.T) { | ||
| @@ -24,6 +27,12 @@ func TestSetEngineDefaults(t *testing.T) { | |||||
| 24 | assert.Equal(t, "auto", mgr.viper.GetString("engine.webkit.gl_rendering_mode")) | 27 | assert.Equal(t, "auto", mgr.viper.GetString("engine.webkit.gl_rendering_mode")) | ||
| 25 | assert.False(t, mgr.viper.GetBool("engine.webkit.disable_dmabuf_renderer")) | 28 | assert.False(t, mgr.viper.GetBool("engine.webkit.disable_dmabuf_renderer")) | ||
| 26 | 29 | ||||
| 30 | + | // Transcoding defaults | |||
| 31 | + | assert.True(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 | + | ||||
| 27 | // Old sections should NOT be registered as defaults | 36 | // Old sections should NOT be registered as defaults | ||
| 28 | assert.Empty(t, mgr.viper.GetString("privacy.cookie_policy")) | 37 | assert.Empty(t, mgr.viper.GetString("privacy.cookie_policy")) | ||
| 29 | assert.Empty(t, mgr.viper.GetString("rendering.mode")) | 38 | assert.Empty(t, mgr.viper.GetString("rendering.mode")) | ||
| @@ -39,3 +48,34 @@ func TestNormalizeConfig_EngineCookiePolicy(t *testing.T) { | |||||
| 39 | 48 | ||||
| 40 | assert.Equal(t, CookiePolicyAlways, cfg.Engine.CookiePolicy) | 49 | assert.Equal(t, CookiePolicyAlways, cfg.Engine.CookiePolicy) | ||
| 41 | } | 50 | } | ||
| 51 | + | ||||
| 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 | + | ||||
| 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.True(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 -1
| @@ -375,7 +375,7 @@ type TranscodingConfig struct { | |||
| 375 | 375 | // Default: "auto" (tries VAAPI first, then CUDA). | |
| 376 | 376 | HWAccel string `mapstructure:"hwaccel" yaml:"hwaccel" toml:"hwaccel"` | |
| 377 | 377 | // MaxConcurrent is the maximum number of simultaneous transcode sessions. | |
| 378 | - | // Default: 2. | |
| 378 | + | // Default: 3. | |
| 379 | 379 | MaxConcurrent int `mapstructure:"max_concurrent" yaml:"max_concurrent" toml:"max_concurrent"` | |
| 380 | 380 | // Quality controls the encode quality preset: "low", "medium", or "high". | |
| 381 | 381 | // Default: "medium". | |
| @@ -375,7 +375,7 @@ type TranscodingConfig struct { | |||||
| 375 | // Default: "auto" (tries VAAPI first, then CUDA). | 375 | // Default: "auto" (tries VAAPI first, then CUDA). | ||
| 376 | HWAccel string `mapstructure:"hwaccel" yaml:"hwaccel" toml:"hwaccel"` | 376 | HWAccel string `mapstructure:"hwaccel" yaml:"hwaccel" toml:"hwaccel"` | ||
| 377 | // MaxConcurrent is the maximum number of simultaneous transcode sessions. | 377 | // MaxConcurrent is the maximum number of simultaneous transcode sessions. | ||
| 378 | - | // Default: 2. | 378 | + | // Default: 3. |
| 379 | MaxConcurrent int `mapstructure:"max_concurrent" yaml:"max_concurrent" toml:"max_concurrent"` | 379 | MaxConcurrent int `mapstructure:"max_concurrent" yaml:"max_concurrent" toml:"max_concurrent"` | ||
| 380 | // Quality controls the encode quality preset: "low", "medium", or "high". | 380 | // Quality controls the encode quality preset: "low", "medium", or "high". | ||
| 381 | // Default: "medium". | 381 | // Default: "medium". | ||
M internal/infrastructure/config/writer_test.go +7 -0
| @@ -43,6 +43,13 @@ func TestWriteConfigOrdered(t *testing.T) { | |||
| 43 | 43 | } | |
| 44 | 44 | } | |
| 45 | 45 | ||
| 46 | + | if !strings.Contains(string(content), "[transcoding]") { | |
| 47 | + | t.Fatalf("expected transcoding section in generated config, got:\n%s", string(content)) | |
| 48 | + | } | |
| 49 | + | if !strings.Contains(string(content), "enabled = true") { | |
| 50 | + | t.Fatalf("expected transcoding.enabled default in generated config, got:\n%s", string(content)) | |
| 51 | + | } | |
| 52 | + | ||
| 46 | 53 | t.Logf("Found %d sections, all sorted", len(sections)) | |
| 47 | 54 | ||
| 48 | 55 | // Print first 50 lines for debugging | |
| @@ -43,6 +43,13 @@ func TestWriteConfigOrdered(t *testing.T) { | |||||
| 43 | } | 43 | } | ||
| 44 | } | 44 | } | ||
| 45 | 45 | ||||
| 46 | + | if !strings.Contains(string(content), "[transcoding]") { | |||
| 47 | + | t.Fatalf("expected transcoding section in generated config, got:\n%s", string(content)) | |||
| 48 | + | } | |||
| 49 | + | if !strings.Contains(string(content), "enabled = true") { | |||
| 50 | + | t.Fatalf("expected transcoding.enabled default in generated config, got:\n%s", string(content)) | |||
| 51 | + | } | |||
| 52 | + | ||||
| 46 | t.Logf("Found %d sections, all sorted", len(sections)) | 53 | t.Logf("Found %d sections, all sorted", len(sections)) | ||
| 47 | 54 | ||||
| 48 | // Print first 50 lines for debugging | 55 | // Print first 50 lines for debugging | ||
M internal/infrastructure/transcoder/audio.go +4 -4
| @@ -100,7 +100,7 @@ func newAudioTranscoder(inCodecPar, outFmtCtx unsafe.Pointer, inStreamIdx int) ( | |||
| 100 | 100 | return nil, errors.New("failed to create output audio stream") | |
| 101 | 101 | } | |
| 102 | 102 | ||
| 103 | - | outStreamWrap := (*ffmpeg.Stream)(outStreamPtr) | |
| 103 | + | outStreamWrap := ffmpeg.WrapStream(outStreamPtr) | |
| 104 | 104 | outStreamIdx := int(outStreamWrap.Index()) | |
| 105 | 105 | ||
| 106 | 106 | // Copy encoder parameters to the output stream's codecpar. | |
| @@ -271,7 +271,7 @@ func (a *audioTranscoder) processPacket(pkt, outFmtCtx unsafe.Pointer) error { | |||
| 271 | 271 | ||
| 272 | 272 | // Rescale timestamps from encoder time base to output stream time base. | |
| 273 | 273 | outStream := ffmpeg.FmtCtxStream(outFmtCtx, a.outStream) | |
| 274 | - | outStreamWrap := (*ffmpeg.Stream)(outStream) | |
| 274 | + | outStreamWrap := ffmpeg.WrapStream(outStream) | |
| 275 | 275 | ffmpeg.PacketRescaleTs(encPkt, ffmpeg.CodecCtxTimeBase(a.encoder), outStreamWrap.TimeBase()) | |
| 276 | 276 | ||
| 277 | 277 | if wret := ffmpeg.InterleavedWriteFrame(outFmtCtx, encPkt); wret < 0 { | |
| @@ -341,7 +341,7 @@ func (a *audioTranscoder) flush(outFmtCtx unsafe.Pointer, logger zerolog.Logger) | |||
| 341 | 341 | } | |
| 342 | 342 | pktSetStreamIndex(encPkt, int32(a.outStream)) | |
| 343 | 343 | outStream := ffmpeg.FmtCtxStream(outFmtCtx, a.outStream) | |
| 344 | - | outStreamWrap := (*ffmpeg.Stream)(outStream) | |
| 344 | + | outStreamWrap := ffmpeg.WrapStream(outStream) | |
| 345 | 345 | ffmpeg.PacketRescaleTs(encPkt, ffmpeg.CodecCtxTimeBase(a.encoder), outStreamWrap.TimeBase()) | |
| 346 | 346 | ffmpeg.InterleavedWriteFrame(outFmtCtx, encPkt) | |
| 347 | 347 | ffmpeg.PacketUnref(encPkt) | |
| @@ -359,7 +359,7 @@ func (a *audioTranscoder) flush(outFmtCtx unsafe.Pointer, logger zerolog.Logger) | |||
| 359 | 359 | } | |
| 360 | 360 | pktSetStreamIndex(encPkt, int32(a.outStream)) | |
| 361 | 361 | outStream := ffmpeg.FmtCtxStream(outFmtCtx, a.outStream) | |
| 362 | - | outStreamWrap := (*ffmpeg.Stream)(outStream) | |
| 362 | + | outStreamWrap := ffmpeg.WrapStream(outStream) | |
| 363 | 363 | ffmpeg.PacketRescaleTs(encPkt, ffmpeg.CodecCtxTimeBase(a.encoder), outStreamWrap.TimeBase()) | |
| 364 | 364 | ffmpeg.InterleavedWriteFrame(outFmtCtx, encPkt) | |
| 365 | 365 | ffmpeg.PacketUnref(encPkt) | |
| @@ -100,7 +100,7 @@ func newAudioTranscoder(inCodecPar, outFmtCtx unsafe.Pointer, inStreamIdx int) ( | |||||
| 100 | return nil, errors.New("failed to create output audio stream") | 100 | return nil, errors.New("failed to create output audio stream") | ||
| 101 | } | 101 | } | ||
| 102 | 102 | ||||
| 103 | - | outStreamWrap := (*ffmpeg.Stream)(outStreamPtr) | 103 | + | outStreamWrap := ffmpeg.WrapStream(outStreamPtr) |
| 104 | outStreamIdx := int(outStreamWrap.Index()) | 104 | outStreamIdx := int(outStreamWrap.Index()) | ||
| 105 | 105 | ||||
| 106 | // Copy encoder parameters to the output stream's codecpar. | 106 | // Copy encoder parameters to the output stream's codecpar. | ||
| @@ -271,7 +271,7 @@ func (a *audioTranscoder) processPacket(pkt, outFmtCtx unsafe.Pointer) error { | |||||
| 271 | 271 | ||||
| 272 | // Rescale timestamps from encoder time base to output stream time base. | 272 | // Rescale timestamps from encoder time base to output stream time base. | ||
| 273 | outStream := ffmpeg.FmtCtxStream(outFmtCtx, a.outStream) | 273 | outStream := ffmpeg.FmtCtxStream(outFmtCtx, a.outStream) | ||
| 274 | - | outStreamWrap := (*ffmpeg.Stream)(outStream) | 274 | + | outStreamWrap := ffmpeg.WrapStream(outStream) |
| 275 | ffmpeg.PacketRescaleTs(encPkt, ffmpeg.CodecCtxTimeBase(a.encoder), outStreamWrap.TimeBase()) | 275 | ffmpeg.PacketRescaleTs(encPkt, ffmpeg.CodecCtxTimeBase(a.encoder), outStreamWrap.TimeBase()) | ||
| 276 | 276 | ||||
| 277 | if wret := ffmpeg.InterleavedWriteFrame(outFmtCtx, encPkt); wret < 0 { | 277 | if wret := ffmpeg.InterleavedWriteFrame(outFmtCtx, encPkt); wret < 0 { | ||
| @@ -341,7 +341,7 @@ func (a *audioTranscoder) flush(outFmtCtx unsafe.Pointer, logger zerolog.Logger) | |||||
| 341 | } | 341 | } | ||
| 342 | pktSetStreamIndex(encPkt, int32(a.outStream)) | 342 | pktSetStreamIndex(encPkt, int32(a.outStream)) | ||
| 343 | outStream := ffmpeg.FmtCtxStream(outFmtCtx, a.outStream) | 343 | outStream := ffmpeg.FmtCtxStream(outFmtCtx, a.outStream) | ||
| 344 | - | outStreamWrap := (*ffmpeg.Stream)(outStream) | 344 | + | outStreamWrap := ffmpeg.WrapStream(outStream) |
| 345 | ffmpeg.PacketRescaleTs(encPkt, ffmpeg.CodecCtxTimeBase(a.encoder), outStreamWrap.TimeBase()) | 345 | ffmpeg.PacketRescaleTs(encPkt, ffmpeg.CodecCtxTimeBase(a.encoder), outStreamWrap.TimeBase()) | ||
| 346 | ffmpeg.InterleavedWriteFrame(outFmtCtx, encPkt) | 346 | ffmpeg.InterleavedWriteFrame(outFmtCtx, encPkt) | ||
| 347 | ffmpeg.PacketUnref(encPkt) | 347 | ffmpeg.PacketUnref(encPkt) | ||
| @@ -359,7 +359,7 @@ func (a *audioTranscoder) flush(outFmtCtx unsafe.Pointer, logger zerolog.Logger) | |||||
| 359 | } | 359 | } | ||
| 360 | pktSetStreamIndex(encPkt, int32(a.outStream)) | 360 | pktSetStreamIndex(encPkt, int32(a.outStream)) | ||
| 361 | outStream := ffmpeg.FmtCtxStream(outFmtCtx, a.outStream) | 361 | outStream := ffmpeg.FmtCtxStream(outFmtCtx, a.outStream) | ||
| 362 | - | outStreamWrap := (*ffmpeg.Stream)(outStream) | 362 | + | outStreamWrap := ffmpeg.WrapStream(outStream) |
| 363 | ffmpeg.PacketRescaleTs(encPkt, ffmpeg.CodecCtxTimeBase(a.encoder), outStreamWrap.TimeBase()) | 363 | ffmpeg.PacketRescaleTs(encPkt, ffmpeg.CodecCtxTimeBase(a.encoder), outStreamWrap.TimeBase()) | ||
| 364 | ffmpeg.InterleavedWriteFrame(outFmtCtx, encPkt) | 364 | ffmpeg.InterleavedWriteFrame(outFmtCtx, encPkt) | ||
| 365 | ffmpeg.PacketUnref(encPkt) | 365 | ffmpeg.PacketUnref(encPkt) | ||
M internal/infrastructure/transcoder/detect.go +111 -0
| @@ -2,6 +2,8 @@ package transcoder | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "mime" | |
| 5 | + | "net/url" | |
| 6 | + | "path" | |
| 5 | 7 | "strings" | |
| 6 | 8 | ) | |
| 7 | 9 | ||
| @@ -29,6 +31,29 @@ var openVideoMIMEs = map[string]bool{ | |||
| 29 | 31 | "video/ogg": true, // Theora + Vorbis | |
| 30 | 32 | } | |
| 31 | 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, | |
| 38 | + | "application/x-mpegurl": true, | |
| 39 | + | "application/dash+xml": true, | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | var proprietaryVideoExtensions = map[string]bool{ | |
| 43 | + | ".mp4": true, | |
| 44 | + | ".m4v": true, | |
| 45 | + | ".mov": true, | |
| 46 | + | ".mkv": true, | |
| 47 | + | ".avi": true, | |
| 48 | + | ".flv": true, | |
| 49 | + | ".3gp": true, | |
| 50 | + | ".3g2": true, | |
| 51 | + | ".mpeg": true, | |
| 52 | + | ".mpg": true, | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | const syntheticTranscodePath = "/__dumber__/transcode.webm" | |
| 56 | + | ||
| 32 | 57 | // IsProprietaryVideoMIME returns true if the MIME type indicates a video | |
| 33 | 58 | // format that typically carries proprietary codecs requiring transcoding. | |
| 34 | 59 | func IsProprietaryVideoMIME(contentType string) bool { | |
| @@ -43,6 +68,92 @@ func IsOpenVideoMIME(contentType string) bool { | |||
| 43 | 68 | return openVideoMIMEs[mediaType] | |
| 44 | 69 | } | |
| 45 | 70 | ||
| 71 | + | // IsStreamingManifestMIME returns true for HLS/DASH manifest MIME types. | |
| 72 | + | func IsStreamingManifestMIME(contentType string) bool { | |
| 73 | + | mediaType := parseMediaType(contentType) | |
| 74 | + | return streamingManifestMIMEs[mediaType] | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // IsStreamingManifestURL returns true for URLs whose path indicates an HLS or | |
| 78 | + | // DASH manifest entrypoint. | |
| 79 | + | func IsStreamingManifestURL(rawURL string) bool { | |
| 80 | + | if rawURL == "" { | |
| 81 | + | return false | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | parsed, err := url.Parse(rawURL) | |
| 85 | + | if err != nil { | |
| 86 | + | return hasManifestExtension(rawURL) | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | return hasManifestExtension(parsed.Path) | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | // IsEagerTranscodeURL returns true for URLs that can be identified as | |
| 93 | + | // transcodable before any response MIME is received. | |
| 94 | + | func IsEagerTranscodeURL(rawURL string) bool { | |
| 95 | + | if rawURL == "" { | |
| 96 | + | return false | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | if IsSyntheticTranscodeURL(rawURL) { | |
| 100 | + | return true | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | parsed, err := url.Parse(rawURL) | |
| 104 | + | if err != nil { | |
| 105 | + | return hasEagerTranscodeExtension(rawURL) | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | return hasEagerTranscodeExtension(parsed.Path) | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | // IsSyntheticTranscodeURL returns true for same-origin synthetic media URLs | |
| 112 | + | // that should be intercepted and resolved to an underlying source URL. | |
| 113 | + | func IsSyntheticTranscodeURL(rawURL string) bool { | |
| 114 | + | _, _, _, ok := ParseSyntheticTranscodeURL(rawURL) | |
| 115 | + | return ok | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | // ParseSyntheticTranscodeURL extracts the underlying source URL and optional | |
| 119 | + | // forwarded headers from a synthetic transcode URL. | |
| 120 | + | func ParseSyntheticTranscodeURL(rawURL string) (sourceURL, referer, origin string, ok bool) { | |
| 121 | + | if rawURL == "" { | |
| 122 | + | return "", "", "", false | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | parsed, err := url.Parse(rawURL) | |
| 126 | + | if err != nil { | |
| 127 | + | return "", "", "", false | |
| 128 | + | } | |
| 129 | + | if parsed.Path != syntheticTranscodePath { | |
| 130 | + | return "", "", "", false | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | sourceURL = strings.TrimSpace(parsed.Query().Get("src")) | |
| 134 | + | if sourceURL == "" { | |
| 135 | + | return "", "", "", false | |
| 136 | + | } | |
| 137 | + | sourceParsed, err := url.Parse(sourceURL) | |
| 138 | + | if err != nil || sourceParsed.Host == "" || (sourceParsed.Scheme != "http" && sourceParsed.Scheme != "https") { | |
| 139 | + | return "", "", "", false | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | referer = strings.TrimSpace(parsed.Query().Get("referer")) | |
| 143 | + | origin = strings.TrimSpace(parsed.Query().Get("origin")) | |
| 144 | + | return sourceURL, referer, origin, true | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | func hasManifestExtension(value string) bool { | |
| 148 | + | ext := strings.ToLower(path.Ext(value)) | |
| 149 | + | return ext == ".m3u8" || ext == ".mpd" | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | func hasEagerTranscodeExtension(value string) bool { | |
| 153 | + | ext := strings.ToLower(path.Ext(value)) | |
| 154 | + | return hasManifestExtension(value) || proprietaryVideoExtensions[ext] | |
| 155 | + | } | |
| 156 | + | ||
| 46 | 157 | // parseMediaType extracts the MIME type from a Content-Type header value, | |
| 47 | 158 | // stripping parameters like charset or codecs. | |
| 48 | 159 | func parseMediaType(contentType string) string { | |
| @@ -2,6 +2,8 @@ package transcoder | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "mime" | 4 | "mime" | ||
| 5 | + | "net/url" | |||
| 6 | + | "path" | |||
| 5 | "strings" | 7 | "strings" | ||
| 6 | ) | 8 | ) | ||
| 7 | 9 | ||||
| @@ -29,6 +31,29 @@ var openVideoMIMEs = map[string]bool{ | |||||
| 29 | "video/ogg": true, // Theora + Vorbis | 31 | "video/ogg": true, // Theora + Vorbis | ||
| 30 | } | 32 | } | ||
| 31 | 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, | |||
| 38 | + | "application/x-mpegurl": true, | |||
| 39 | + | "application/dash+xml": true, | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | var proprietaryVideoExtensions = map[string]bool{ | |||
| 43 | + | ".mp4": true, | |||
| 44 | + | ".m4v": true, | |||
| 45 | + | ".mov": true, | |||
| 46 | + | ".mkv": true, | |||
| 47 | + | ".avi": true, | |||
| 48 | + | ".flv": true, | |||
| 49 | + | ".3gp": true, | |||
| 50 | + | ".3g2": true, | |||
| 51 | + | ".mpeg": true, | |||
| 52 | + | ".mpg": true, | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | const syntheticTranscodePath = "/__dumber__/transcode.webm" | |||
| 56 | + | ||||
| 32 | // IsProprietaryVideoMIME returns true if the MIME type indicates a video | 57 | // IsProprietaryVideoMIME returns true if the MIME type indicates a video | ||
| 33 | // format that typically carries proprietary codecs requiring transcoding. | 58 | // format that typically carries proprietary codecs requiring transcoding. | ||
| 34 | func IsProprietaryVideoMIME(contentType string) bool { | 59 | func IsProprietaryVideoMIME(contentType string) bool { | ||
| @@ -43,6 +68,92 @@ func IsOpenVideoMIME(contentType string) bool { | |||||
| 43 | return openVideoMIMEs[mediaType] | 68 | return openVideoMIMEs[mediaType] | ||
| 44 | } | 69 | } | ||
| 45 | 70 | ||||
| 71 | + | // IsStreamingManifestMIME returns true for HLS/DASH manifest MIME types. | |||
| 72 | + | func IsStreamingManifestMIME(contentType string) bool { | |||
| 73 | + | mediaType := parseMediaType(contentType) | |||
| 74 | + | return streamingManifestMIMEs[mediaType] | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | // IsStreamingManifestURL returns true for URLs whose path indicates an HLS or | |||
| 78 | + | // DASH manifest entrypoint. | |||
| 79 | + | func IsStreamingManifestURL(rawURL string) bool { | |||
| 80 | + | if rawURL == "" { | |||
| 81 | + | return false | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | parsed, err := url.Parse(rawURL) | |||
| 85 | + | if err != nil { | |||
| 86 | + | return hasManifestExtension(rawURL) | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | return hasManifestExtension(parsed.Path) | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | // IsEagerTranscodeURL returns true for URLs that can be identified as | |||
| 93 | + | // transcodable before any response MIME is received. | |||
| 94 | + | func IsEagerTranscodeURL(rawURL string) bool { | |||
| 95 | + | if rawURL == "" { | |||
| 96 | + | return false | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | if IsSyntheticTranscodeURL(rawURL) { | |||
| 100 | + | return true | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | parsed, err := url.Parse(rawURL) | |||
| 104 | + | if err != nil { | |||
| 105 | + | return hasEagerTranscodeExtension(rawURL) | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | return hasEagerTranscodeExtension(parsed.Path) | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | // IsSyntheticTranscodeURL returns true for same-origin synthetic media URLs | |||
| 112 | + | // that should be intercepted and resolved to an underlying source URL. | |||
| 113 | + | func IsSyntheticTranscodeURL(rawURL string) bool { | |||
| 114 | + | _, _, _, ok := ParseSyntheticTranscodeURL(rawURL) | |||
| 115 | + | return ok | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | // ParseSyntheticTranscodeURL extracts the underlying source URL and optional | |||
| 119 | + | // forwarded headers from a synthetic transcode URL. | |||
| 120 | + | func ParseSyntheticTranscodeURL(rawURL string) (sourceURL, referer, origin string, ok bool) { | |||
| 121 | + | if rawURL == "" { | |||
| 122 | + | return "", "", "", false | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | parsed, err := url.Parse(rawURL) | |||
| 126 | + | if err != nil { | |||
| 127 | + | return "", "", "", false | |||
| 128 | + | } | |||
| 129 | + | if parsed.Path != syntheticTranscodePath { | |||
| 130 | + | return "", "", "", false | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | sourceURL = strings.TrimSpace(parsed.Query().Get("src")) | |||
| 134 | + | if sourceURL == "" { | |||
| 135 | + | return "", "", "", false | |||
| 136 | + | } | |||
| 137 | + | sourceParsed, err := url.Parse(sourceURL) | |||
| 138 | + | if err != nil || sourceParsed.Host == "" || (sourceParsed.Scheme != "http" && sourceParsed.Scheme != "https") { | |||
| 139 | + | return "", "", "", false | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | referer = strings.TrimSpace(parsed.Query().Get("referer")) | |||
| 143 | + | origin = strings.TrimSpace(parsed.Query().Get("origin")) | |||
| 144 | + | return sourceURL, referer, origin, true | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | func hasManifestExtension(value string) bool { | |||
| 148 | + | ext := strings.ToLower(path.Ext(value)) | |||
| 149 | + | return ext == ".m3u8" || ext == ".mpd" | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | func hasEagerTranscodeExtension(value string) bool { | |||
| 153 | + | ext := strings.ToLower(path.Ext(value)) | |||
| 154 | + | return hasManifestExtension(value) || proprietaryVideoExtensions[ext] | |||
| 155 | + | } | |||
| 156 | + | ||||
| 46 | // parseMediaType extracts the MIME type from a Content-Type header value, | 157 | // parseMediaType extracts the MIME type from a Content-Type header value, | ||
| 47 | // stripping parameters like charset or codecs. | 158 | // stripping parameters like charset or codecs. | ||
| 48 | func parseMediaType(contentType string) string { | 159 | func parseMediaType(contentType string) string { | ||
M internal/infrastructure/transcoder/detect_test.go +95 -0
| @@ -84,3 +84,98 @@ func TestIsOpenVideoMIME(t *testing.T) { | |||
| 84 | 84 | }) | |
| 85 | 85 | } | |
| 86 | 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: "dash", contentType: "application/dash+xml", want: true}, | |
| 97 | + | {name: "dash with charset", contentType: "application/dash+xml; charset=utf-8", want: true}, | |
| 98 | + | {name: "mp4", contentType: "video/mp4", want: false}, | |
| 99 | + | {name: "json", contentType: "application/json", want: false}, | |
| 100 | + | {name: "empty", contentType: "", want: false}, | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | for _, tt := range tests { | |
| 104 | + | t.Run(tt.name, func(t *testing.T) { | |
| 105 | + | got := IsStreamingManifestMIME(tt.contentType) | |
| 106 | + | if got != tt.want { | |
| 107 | + | t.Errorf("IsStreamingManifestMIME(%q) = %v, want %v", tt.contentType, got, tt.want) | |
| 108 | + | } | |
| 109 | + | }) | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func TestIsStreamingManifestURL(t *testing.T) { | |
| 114 | + | tests := []struct { | |
| 115 | + | name string | |
| 116 | + | rawURL string | |
| 117 | + | want bool | |
| 118 | + | }{ | |
| 119 | + | {name: "hls manifest", rawURL: "https://v.redd.it/abc/HLSPlaylist.m3u8?f=sd&v=1", want: true}, | |
| 120 | + | {name: "dash manifest", rawURL: "https://example.com/video/manifest.mpd", want: true}, | |
| 121 | + | {name: "uppercase extension", rawURL: "https://example.com/PLAYLIST.M3U8", want: true}, | |
| 122 | + | {name: "segment ts", rawURL: "https://example.com/chunk.ts", want: false}, | |
| 123 | + | {name: "plain mp4", rawURL: "https://example.com/video.mp4", want: false}, | |
| 124 | + | {name: "empty", rawURL: "", want: false}, | |
| 125 | + | {name: "invalid but obvious", rawURL: "not a url but playlist.m3u8", want: true}, | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | for _, tt := range tests { | |
| 129 | + | t.Run(tt.name, func(t *testing.T) { | |
| 130 | + | got := IsStreamingManifestURL(tt.rawURL) | |
| 131 | + | if got != tt.want { | |
| 132 | + | t.Errorf("IsStreamingManifestURL(%q) = %v, want %v", tt.rawURL, got, tt.want) | |
| 133 | + | } | |
| 134 | + | }) | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | func TestIsEagerTranscodeURL(t *testing.T) { | |
| 139 | + | tests := []struct { | |
| 140 | + | name string | |
| 141 | + | rawURL string | |
| 142 | + | want bool | |
| 143 | + | }{ | |
| 144 | + | {name: "hls manifest", rawURL: "https://v.redd.it/abc/HLSPlaylist.m3u8?f=sd&v=1", want: true}, | |
| 145 | + | {name: "dash manifest", rawURL: "https://example.com/video/manifest.mpd", want: true}, | |
| 146 | + | {name: "mp4 file", rawURL: "https://example.com/video.mp4", want: true}, | |
| 147 | + | {name: "mov file", rawURL: "https://example.com/video.mov", want: true}, | |
| 148 | + | {name: "mkv uppercase", rawURL: "https://example.com/VIDEO.MKV", want: true}, | |
| 149 | + | {name: "hls segment", rawURL: "https://example.com/chunk.ts", want: false}, | |
| 150 | + | {name: "webm open codec", rawURL: "https://example.com/video.webm", want: false}, | |
| 151 | + | {name: "synthetic transcode", rawURL: "https://www.reddit.com/__dumber__/transcode.webm?src=https%3A%2F%2Fv.redd.it%2Fabc%2FHLSPlaylist.m3u8", want: true}, | |
| 152 | + | {name: "empty", rawURL: "", want: false}, | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | for _, tt := range tests { | |
| 156 | + | t.Run(tt.name, func(t *testing.T) { | |
| 157 | + | got := IsEagerTranscodeURL(tt.rawURL) | |
| 158 | + | if got != tt.want { | |
| 159 | + | t.Errorf("IsEagerTranscodeURL(%q) = %v, want %v", tt.rawURL, got, tt.want) | |
| 160 | + | } | |
| 161 | + | }) | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | func TestParseSyntheticTranscodeURL(t *testing.T) { | |
| 166 | + | 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" | |
| 167 | + | ||
| 168 | + | sourceURL, referer, origin, ok := ParseSyntheticTranscodeURL(rawURL) | |
| 169 | + | if !ok { | |
| 170 | + | t.Fatalf("ParseSyntheticTranscodeURL(%q) = !ok, want ok", rawURL) | |
| 171 | + | } | |
| 172 | + | if sourceURL != "https://v.redd.it/abc/HLSPlaylist.m3u8" { | |
| 173 | + | t.Fatalf("sourceURL = %q", sourceURL) | |
| 174 | + | } | |
| 175 | + | if referer != "https://www.reddit.com/r/OpenAI" { | |
| 176 | + | t.Fatalf("referer = %q", referer) | |
| 177 | + | } | |
| 178 | + | if origin != "https://www.reddit.com" { | |
| 179 | + | t.Fatalf("origin = %q", origin) | |
| 180 | + | } | |
| 181 | + | } | |
| @@ -84,3 +84,98 @@ func TestIsOpenVideoMIME(t *testing.T) { | |||||
| 84 | }) | 84 | }) | ||
| 85 | } | 85 | } | ||
| 86 | } | 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: "dash", contentType: "application/dash+xml", want: true}, | |||
| 97 | + | {name: "dash with charset", contentType: "application/dash+xml; charset=utf-8", want: true}, | |||
| 98 | + | {name: "mp4", contentType: "video/mp4", want: false}, | |||
| 99 | + | {name: "json", contentType: "application/json", want: false}, | |||
| 100 | + | {name: "empty", contentType: "", want: false}, | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | for _, tt := range tests { | |||
| 104 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 105 | + | got := IsStreamingManifestMIME(tt.contentType) | |||
| 106 | + | if got != tt.want { | |||
| 107 | + | t.Errorf("IsStreamingManifestMIME(%q) = %v, want %v", tt.contentType, got, tt.want) | |||
| 108 | + | } | |||
| 109 | + | }) | |||
| 110 | + | } | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | func TestIsStreamingManifestURL(t *testing.T) { | |||
| 114 | + | tests := []struct { | |||
| 115 | + | name string | |||
| 116 | + | rawURL string | |||
| 117 | + | want bool | |||
| 118 | + | }{ | |||
| 119 | + | {name: "hls manifest", rawURL: "https://v.redd.it/abc/HLSPlaylist.m3u8?f=sd&v=1", want: true}, | |||
| 120 | + | {name: "dash manifest", rawURL: "https://example.com/video/manifest.mpd", want: true}, | |||
| 121 | + | {name: "uppercase extension", rawURL: "https://example.com/PLAYLIST.M3U8", want: true}, | |||
| 122 | + | {name: "segment ts", rawURL: "https://example.com/chunk.ts", want: false}, | |||
| 123 | + | {name: "plain mp4", rawURL: "https://example.com/video.mp4", want: false}, | |||
| 124 | + | {name: "empty", rawURL: "", want: false}, | |||
| 125 | + | {name: "invalid but obvious", rawURL: "not a url but playlist.m3u8", want: true}, | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | for _, tt := range tests { | |||
| 129 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 130 | + | got := IsStreamingManifestURL(tt.rawURL) | |||
| 131 | + | if got != tt.want { | |||
| 132 | + | t.Errorf("IsStreamingManifestURL(%q) = %v, want %v", tt.rawURL, got, tt.want) | |||
| 133 | + | } | |||
| 134 | + | }) | |||
| 135 | + | } | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | func TestIsEagerTranscodeURL(t *testing.T) { | |||
| 139 | + | tests := []struct { | |||
| 140 | + | name string | |||
| 141 | + | rawURL string | |||
| 142 | + | want bool | |||
| 143 | + | }{ | |||
| 144 | + | {name: "hls manifest", rawURL: "https://v.redd.it/abc/HLSPlaylist.m3u8?f=sd&v=1", want: true}, | |||
| 145 | + | {name: "dash manifest", rawURL: "https://example.com/video/manifest.mpd", want: true}, | |||
| 146 | + | {name: "mp4 file", rawURL: "https://example.com/video.mp4", want: true}, | |||
| 147 | + | {name: "mov file", rawURL: "https://example.com/video.mov", want: true}, | |||
| 148 | + | {name: "mkv uppercase", rawURL: "https://example.com/VIDEO.MKV", want: true}, | |||
| 149 | + | {name: "hls segment", rawURL: "https://example.com/chunk.ts", want: false}, | |||
| 150 | + | {name: "webm open codec", rawURL: "https://example.com/video.webm", want: false}, | |||
| 151 | + | {name: "synthetic transcode", rawURL: "https://www.reddit.com/__dumber__/transcode.webm?src=https%3A%2F%2Fv.redd.it%2Fabc%2FHLSPlaylist.m3u8", want: true}, | |||
| 152 | + | {name: "empty", rawURL: "", want: false}, | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | for _, tt := range tests { | |||
| 156 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 157 | + | got := IsEagerTranscodeURL(tt.rawURL) | |||
| 158 | + | if got != tt.want { | |||
| 159 | + | t.Errorf("IsEagerTranscodeURL(%q) = %v, want %v", tt.rawURL, got, tt.want) | |||
| 160 | + | } | |||
| 161 | + | }) | |||
| 162 | + | } | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | func TestParseSyntheticTranscodeURL(t *testing.T) { | |||
| 166 | + | 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" | |||
| 167 | + | ||||
| 168 | + | sourceURL, referer, origin, ok := ParseSyntheticTranscodeURL(rawURL) | |||
| 169 | + | if !ok { | |||
| 170 | + | t.Fatalf("ParseSyntheticTranscodeURL(%q) = !ok, want ok", rawURL) | |||
| 171 | + | } | |||
| 172 | + | if sourceURL != "https://v.redd.it/abc/HLSPlaylist.m3u8" { | |||
| 173 | + | t.Fatalf("sourceURL = %q", sourceURL) | |||
| 174 | + | } | |||
| 175 | + | if referer != "https://www.reddit.com/r/OpenAI" { | |||
| 176 | + | t.Fatalf("referer = %q", referer) | |||
| 177 | + | } | |||
| 178 | + | if origin != "https://www.reddit.com" { | |||
| 179 | + | t.Fatalf("origin = %q", origin) | |||
| 180 | + | } | |||
| 181 | + | } | |||
M internal/infrastructure/transcoder/ffmpeg_offsets.go +30 -24
| @@ -27,28 +27,30 @@ func pktSetStreamIndex(pkt unsafe.Pointer, idx int32) { | |||
| 27 | 27 | } | |
| 28 | 28 | ||
| 29 | 29 | // --------------------------------------------------------------------------- | |
| 30 | - | // AVFrame field offsets (FFmpeg 7.x, libavutil 60) | |
| 30 | + | // AVFrame field offsets (FFmpeg 8.x, libavutil 60) | |
| 31 | 31 | // Layout (relevant fields): | |
| 32 | - | // data[8] at offset 0 (8 pointers = 64 bytes) | |
| 33 | - | // linesize[8] at offset 64 (8 x int32 = 32 bytes) | |
| 32 | + | // data[8] at offset 0 (8 pointers = 64 bytes) | |
| 33 | + | // linesize[8] at offset 64 (8 x int32 = 32 bytes) | |
| 34 | 34 | // ... | |
| 35 | - | // width at offset 268 | |
| 36 | - | // height at offset 272 | |
| 37 | - | // nb_samples at offset 276 | |
| 38 | - | // format at offset 280 | |
| 39 | - | // pts at offset 328 | |
| 40 | - | // ... | |
| 41 | - | // sample_rate at offset 432 | |
| 42 | - | // ch_layout at offset 440 (AVChannelLayout, 24 bytes) | |
| 35 | + | // width at offset 104 | |
| 36 | + | // height at offset 108 | |
| 37 | + | // nb_samples at offset 112 | |
| 38 | + | // format at offset 116 | |
| 39 | + | // pts at offset 136 | |
| 40 | + | // sample_rate at offset 180 | |
| 41 | + | // hw_frames_ctx at offset 328 (AVBufferRef*) | |
| 42 | + | // ch_layout at offset 384 (AVChannelLayout, 24 bytes) | |
| 43 | + | // hw_frames_ctx at offset 328 (AVBufferRef*) | |
| 43 | 44 | // --------------------------------------------------------------------------- | |
| 44 | 45 | ||
| 45 | 46 | const ( | |
| 46 | - | offsetFrameWidth = 268 | |
| 47 | - | offsetFrameHeight = 272 | |
| 48 | - | offsetFrameNbSamples = 276 | |
| 49 | - | offsetFrameFormat = 280 | |
| 50 | - | offsetFramePts = 328 | |
| 51 | - | offsetFrameSampleRate = 432 | |
| 47 | + | offsetFrameWidth = 104 | |
| 48 | + | offsetFrameHeight = 108 | |
| 49 | + | offsetFrameNbSamples = 112 | |
| 50 | + | offsetFrameFormat = 116 | |
| 51 | + | offsetFrameHWFramesCtx = 328 | |
| 52 | + | offsetFramePts = 136 | |
| 53 | + | offsetFrameSampleRate = 180 | |
| 52 | 54 | ) | |
| 53 | 55 | ||
| 54 | 56 | func frameWidth(f unsafe.Pointer) int32 { | |
| @@ -83,6 +85,10 @@ func frameSetFormat(f unsafe.Pointer, v int32) { | |||
| 83 | 85 | *(*int32)(unsafe.Add(f, offsetFrameFormat)) = v | |
| 84 | 86 | } | |
| 85 | 87 | ||
| 88 | + | func frameSetHWFramesCtx(f unsafe.Pointer, v unsafe.Pointer) { | |
| 89 | + | *(*unsafe.Pointer)(unsafe.Add(f, offsetFrameHWFramesCtx)) = v | |
| 90 | + | } | |
| 91 | + | ||
| 86 | 92 | func framePts(f unsafe.Pointer) int64 { | |
| 87 | 93 | return *(*int64)(unsafe.Add(f, offsetFramePts)) | |
| 88 | 94 | } | |
| @@ -101,14 +107,14 @@ func frameSetSampleRate(f unsafe.Pointer, v int32) { | |||
| 101 | 107 | ||
| 102 | 108 | // --------------------------------------------------------------------------- | |
| 103 | 109 | // AVCodecContext additional offsets | |
| 104 | - | // flags at offset 72 (int32, but stored as uint32 in practice) | |
| 110 | + | // Verified against the installed libavcodec headers via offsetof(). | |
| 105 | 111 | // --------------------------------------------------------------------------- | |
| 106 | 112 | ||
| 107 | 113 | const ( | |
| 108 | - | offsetCodecCtxFlags = 72 | |
| 109 | - | offsetCodecCtxGopSize = 120 | |
| 110 | - | offsetCodecCtxBitRate = 28 | |
| 111 | - | offsetCodecCtxChLayout = 368 // AVChannelLayout struct (24 bytes) | |
| 114 | + | offsetCodecCtxFlags = 64 | |
| 115 | + | offsetCodecCtxGopSize = 332 | |
| 116 | + | offsetCodecCtxBitRate = 56 | |
| 117 | + | offsetCodecCtxChLayout = 352 // AVChannelLayout struct (24 bytes) | |
| 112 | 118 | ) | |
| 113 | 119 | ||
| 114 | 120 | // AV_CODEC_FLAG_GLOBAL_HEADER indicates the codec uses global headers | |
| @@ -136,7 +142,7 @@ func codecCtxSetBitRate(ctx unsafe.Pointer, v int64) { | |||
| 136 | 142 | func codecCtxCopyChLayout(dst, src unsafe.Pointer) { | |
| 137 | 143 | dstLayout := unsafe.Add(dst, offsetCodecCtxChLayout) | |
| 138 | 144 | srcLayout := unsafe.Add(src, offsetCodecCtxChLayout) | |
| 139 | - | // AVChannelLayout is 24 bytes in FFmpeg 7.x. | |
| 145 | + | // AVChannelLayout is 24 bytes in FFmpeg 8.x. | |
| 140 | 146 | copy( | |
| 141 | 147 | unsafe.Slice((*byte)(dstLayout), 24), | |
| 142 | 148 | unsafe.Slice((*byte)(srcLayout), 24), | |
| @@ -149,7 +155,7 @@ func codecCtxCopyChLayout(dst, src unsafe.Pointer) { | |||
| 149 | 155 | ||
| 150 | 156 | const ( | |
| 151 | 157 | offsetCodecParSampleRate = 152 | |
| 152 | - | offsetCodecParChLayout = 56 // AVChannelLayout (24 bytes) at offset 56 | |
| 158 | + | offsetCodecParChLayout = 128 // AVChannelLayout (24 bytes) | |
| 153 | 159 | ) | |
| 154 | 160 | ||
| 155 | 161 | func codecParSampleRate(par unsafe.Pointer) int32 { | |
| @@ -27,28 +27,30 @@ func pktSetStreamIndex(pkt unsafe.Pointer, idx int32) { | |||||
| 27 | } | 27 | } | ||
| 28 | 28 | ||||
| 29 | // --------------------------------------------------------------------------- | 29 | // --------------------------------------------------------------------------- | ||
| 30 | - | // AVFrame field offsets (FFmpeg 7.x, libavutil 60) | 30 | + | // AVFrame field offsets (FFmpeg 8.x, libavutil 60) |
| 31 | // Layout (relevant fields): | 31 | // Layout (relevant fields): | ||
| 32 | - | // data[8] at offset 0 (8 pointers = 64 bytes) | 32 | + | // data[8] at offset 0 (8 pointers = 64 bytes) |
| 33 | - | // linesize[8] at offset 64 (8 x int32 = 32 bytes) | 33 | + | // linesize[8] at offset 64 (8 x int32 = 32 bytes) |
| 34 | // ... | 34 | // ... | ||
| 35 | - | // width at offset 268 | 35 | + | // width at offset 104 |
| 36 | - | // height at offset 272 | 36 | + | // height at offset 108 |
| 37 | - | // nb_samples at offset 276 | 37 | + | // nb_samples at offset 112 |
| 38 | - | // format at offset 280 | 38 | + | // format at offset 116 |
| 39 | - | // pts at offset 328 | 39 | + | // pts at offset 136 |
| 40 | - | // ... | 40 | + | // sample_rate at offset 180 |
| 41 | - | // sample_rate at offset 432 | 41 | + | // hw_frames_ctx at offset 328 (AVBufferRef*) |
| 42 | - | // ch_layout at offset 440 (AVChannelLayout, 24 bytes) | 42 | + | // ch_layout at offset 384 (AVChannelLayout, 24 bytes) |
| 43 | + | // hw_frames_ctx at offset 328 (AVBufferRef*) | |||
| 43 | // --------------------------------------------------------------------------- | 44 | // --------------------------------------------------------------------------- | ||
| 44 | 45 | ||||
| 45 | const ( | 46 | const ( | ||
| 46 | - | offsetFrameWidth = 268 | 47 | + | offsetFrameWidth = 104 |
| 47 | - | offsetFrameHeight = 272 | 48 | + | offsetFrameHeight = 108 |
| 48 | - | offsetFrameNbSamples = 276 | 49 | + | offsetFrameNbSamples = 112 |
| 49 | - | offsetFrameFormat = 280 | 50 | + | offsetFrameFormat = 116 |
| 50 | - | offsetFramePts = 328 | 51 | + | offsetFrameHWFramesCtx = 328 |
| 51 | - | offsetFrameSampleRate = 432 | 52 | + | offsetFramePts = 136 |
| 53 | + | offsetFrameSampleRate = 180 | |||
| 52 | ) | 54 | ) | ||
| 53 | 55 | ||||
| 54 | func frameWidth(f unsafe.Pointer) int32 { | 56 | func frameWidth(f unsafe.Pointer) int32 { | ||
| @@ -83,6 +85,10 @@ func frameSetFormat(f unsafe.Pointer, v int32) { | |||||
| 83 | *(*int32)(unsafe.Add(f, offsetFrameFormat)) = v | 85 | *(*int32)(unsafe.Add(f, offsetFrameFormat)) = v | ||
| 84 | } | 86 | } | ||
| 85 | 87 | ||||
| 88 | + | func frameSetHWFramesCtx(f unsafe.Pointer, v unsafe.Pointer) { | |||
| 89 | + | *(*unsafe.Pointer)(unsafe.Add(f, offsetFrameHWFramesCtx)) = v | |||
| 90 | + | } | |||
| 91 | + | ||||
| 86 | func framePts(f unsafe.Pointer) int64 { | 92 | func framePts(f unsafe.Pointer) int64 { | ||
| 87 | return *(*int64)(unsafe.Add(f, offsetFramePts)) | 93 | return *(*int64)(unsafe.Add(f, offsetFramePts)) | ||
| 88 | } | 94 | } | ||
| @@ -101,14 +107,14 @@ func frameSetSampleRate(f unsafe.Pointer, v int32) { | |||||
| 101 | 107 | ||||
| 102 | // --------------------------------------------------------------------------- | 108 | // --------------------------------------------------------------------------- | ||
| 103 | // AVCodecContext additional offsets | 109 | // AVCodecContext additional offsets | ||
| 104 | - | // flags at offset 72 (int32, but stored as uint32 in practice) | 110 | + | // Verified against the installed libavcodec headers via offsetof(). |
| 105 | // --------------------------------------------------------------------------- | 111 | // --------------------------------------------------------------------------- | ||
| 106 | 112 | ||||
| 107 | const ( | 113 | const ( | ||
| 108 | - | offsetCodecCtxFlags = 72 | 114 | + | offsetCodecCtxFlags = 64 |
| 109 | - | offsetCodecCtxGopSize = 120 | 115 | + | offsetCodecCtxGopSize = 332 |
| 110 | - | offsetCodecCtxBitRate = 28 | 116 | + | offsetCodecCtxBitRate = 56 |
| 111 | - | offsetCodecCtxChLayout = 368 // AVChannelLayout struct (24 bytes) | 117 | + | offsetCodecCtxChLayout = 352 // AVChannelLayout struct (24 bytes) |
| 112 | ) | 118 | ) | ||
| 113 | 119 | ||||
| 114 | // AV_CODEC_FLAG_GLOBAL_HEADER indicates the codec uses global headers | 120 | // AV_CODEC_FLAG_GLOBAL_HEADER indicates the codec uses global headers | ||
| @@ -136,7 +142,7 @@ func codecCtxSetBitRate(ctx unsafe.Pointer, v int64) { | |||||
| 136 | func codecCtxCopyChLayout(dst, src unsafe.Pointer) { | 142 | func codecCtxCopyChLayout(dst, src unsafe.Pointer) { | ||
| 137 | dstLayout := unsafe.Add(dst, offsetCodecCtxChLayout) | 143 | dstLayout := unsafe.Add(dst, offsetCodecCtxChLayout) | ||
| 138 | srcLayout := unsafe.Add(src, offsetCodecCtxChLayout) | 144 | srcLayout := unsafe.Add(src, offsetCodecCtxChLayout) | ||
| 139 | - | // AVChannelLayout is 24 bytes in FFmpeg 7.x. | 145 | + | // AVChannelLayout is 24 bytes in FFmpeg 8.x. |
| 140 | copy( | 146 | copy( | ||
| 141 | unsafe.Slice((*byte)(dstLayout), 24), | 147 | unsafe.Slice((*byte)(dstLayout), 24), | ||
| 142 | unsafe.Slice((*byte)(srcLayout), 24), | 148 | unsafe.Slice((*byte)(srcLayout), 24), | ||
| @@ -149,7 +155,7 @@ func codecCtxCopyChLayout(dst, src unsafe.Pointer) { | |||||
| 149 | 155 | ||||
| 150 | const ( | 156 | const ( | ||
| 151 | offsetCodecParSampleRate = 152 | 157 | offsetCodecParSampleRate = 152 | ||
| 152 | - | offsetCodecParChLayout = 56 // AVChannelLayout (24 bytes) at offset 56 | 158 | + | offsetCodecParChLayout = 128 // AVChannelLayout (24 bytes) |
| 153 | ) | 159 | ) | ||
| 154 | 160 | ||||
| 155 | func codecParSampleRate(par unsafe.Pointer) int32 { | 161 | func codecParSampleRate(par unsafe.Pointer) int32 { | ||
M internal/infrastructure/transcoder/pipeline.go +372 -136
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "fmt" | |
| 7 | 7 | "io" | |
| 8 | 8 | "net/http" | |
| 9 | + | "sort" | |
| 9 | 10 | "strings" | |
| 10 | 11 | "unsafe" | |
| 11 | 12 | ||
| @@ -29,6 +30,7 @@ const avioBufSize = 32 * 1024 | |||
| 29 | 30 | // Opus via libopus, and muxes the result into WebM written to an | |
| 30 | 31 | // io.PipeWriter. | |
| 31 | 32 | type pipeline struct { | |
| 33 | + | sessionID string | |
| 32 | 34 | hwCaps port.HWCapabilities | |
| 33 | 35 | sourceURL string | |
| 34 | 36 | headers map[string]string | |
| @@ -53,8 +55,9 @@ var httpClient = &http.Client{ | |||
| 53 | 55 | }, | |
| 54 | 56 | } | |
| 55 | 57 | ||
| 56 | - | func newPipeline(hwCaps port.HWCapabilities, sourceURL string, headers map[string]string, quality string, pw *io.PipeWriter, logger zerolog.Logger) *pipeline { | |
| 58 | + | func newPipeline(sessionID string, hwCaps port.HWCapabilities, sourceURL string, headers map[string]string, quality string, pw *io.PipeWriter, logger zerolog.Logger) *pipeline { | |
| 57 | 59 | return &pipeline{ | |
| 60 | + | sessionID: sessionID, | |
| 58 | 61 | hwCaps: hwCaps, | |
| 59 | 62 | sourceURL: sourceURL, | |
| 60 | 63 | headers: headers, | |
| @@ -69,8 +72,21 @@ func newPipeline(hwCaps port.HWCapabilities, sourceURL string, headers map[strin | |||
| 69 | 72 | func (p *pipeline) run(ctx context.Context) { | |
| 70 | 73 | err := p.doRun(ctx) | |
| 71 | 74 | if err != nil { | |
| 75 | + | log := p.logger.With(). | |
| 76 | + | Str("session_id", p.sessionID). | |
| 77 | + | Str("source_url", p.sourceURL). | |
| 78 | + | Logger() | |
| 79 | + | if errors.Is(err, context.Canceled) { | |
| 80 | + | log.Debug().Err(err).Msg("transcode pipeline cancelled") | |
| 81 | + | } else { | |
| 82 | + | log.Error().Err(err).Msg("transcode pipeline failed") | |
| 83 | + | } | |
| 72 | 84 | p.pw.CloseWithError(err) | |
| 73 | 85 | } else { | |
| 86 | + | p.logger.Debug(). | |
| 87 | + | Str("session_id", p.sessionID). | |
| 88 | + | Str("source_url", p.sourceURL). | |
| 89 | + | Msg("transcode pipeline completed") | |
| 74 | 90 | p.pw.Close() | |
| 75 | 91 | } | |
| 76 | 92 | } | |
| @@ -78,80 +94,26 @@ func (p *pipeline) run(ctx context.Context) { | |||
| 78 | 94 | // doRun contains the actual pipeline logic. Errors are returned, and | |
| 79 | 95 | // the caller (run) closes the pipe writer accordingly. | |
| 80 | 96 | func (p *pipeline) doRun(ctx context.Context) error { | |
| 81 | - | // --- Step 1: HTTP GET the source --- | |
| 82 | - | req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.sourceURL, nil) | |
| 83 | - | if err != nil { | |
| 84 | - | return fmt.Errorf("create request: %w", err) | |
| 85 | - | } | |
| 86 | - | for k, v := range p.headers { | |
| 87 | - | req.Header.Set(k, v) | |
| 88 | - | } | |
| 89 | - | ||
| 90 | - | resp, err := httpClient.Do(req) | |
| 97 | + | // --- Step 1-3: Open the input using the appropriate strategy --- | |
| 98 | + | inFmtCtx, closeInput, err := p.openInputFormatContext(ctx) | |
| 91 | 99 | if err != nil { | |
| 92 | - | return fmt.Errorf("fetch source: %w", err) | |
| 93 | - | } | |
| 94 | - | defer resp.Body.Close() | |
| 95 | - | ||
| 96 | - | if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent { | |
| 97 | - | return fmt.Errorf("source returned HTTP %d", resp.StatusCode) | |
| 98 | - | } | |
| 99 | - | ||
| 100 | - | // --- Step 2: Create read AVIO callback from HTTP body --- | |
| 101 | - | body := resp.Body | |
| 102 | - | readFn := func(_ unsafe.Pointer, buf unsafe.Pointer, bufSize int32) int32 { | |
| 103 | - | if bufSize <= 0 { | |
| 104 | - | return -1 | |
| 105 | - | } | |
| 106 | - | goBuf := unsafe.Slice((*byte)(buf), int(bufSize)) | |
| 107 | - | n, readErr := body.Read(goBuf) | |
| 108 | - | if n > 0 { | |
| 109 | - | return int32(n) | |
| 110 | - | } | |
| 111 | - | if readErr != nil { | |
| 112 | - | // Return AVERROR_EOF for EOF, generic error otherwise. | |
| 113 | - | return averrorEOF | |
| 114 | - | } | |
| 115 | - | return -1 | |
| 116 | - | } | |
| 117 | - | p.readCb = purego.NewCallback(readFn) | |
| 118 | - | ||
| 119 | - | readAVIO := ffmpeg.AvioAllocContext(avioBufSize, false, nil, p.readCb, 0, 0) | |
| 120 | - | if readAVIO == nil { | |
| 121 | - | return errors.New("failed to allocate read AVIO context") | |
| 122 | - | } | |
| 123 | - | defer ffmpeg.AvioContextFree(unsafe.Pointer(&readAVIO)) | |
| 124 | - | ||
| 125 | - | // --- Step 3: Open input format context with custom AVIO --- | |
| 126 | - | inFmtCtx := ffmpeg.FormatAllocContext() | |
| 127 | - | if inFmtCtx == nil { | |
| 128 | - | return errors.New("failed to allocate input format context") | |
| 129 | - | } | |
| 130 | - | ||
| 131 | - | ffmpeg.FmtCtxSetPB(inFmtCtx, readAVIO) | |
| 132 | - | ffmpeg.FmtCtxSetFlags(inFmtCtx, ffmpeg.FmtCtxFlags(inFmtCtx)|ffmpeg.AVFMT_FLAG_CUSTOM_IO) | |
| 133 | - | ||
| 134 | - | if ret := ffmpeg.FormatOpenInput(unsafe.Pointer(&inFmtCtx), "", nil, nil); ret < 0 { | |
| 135 | - | ffmpeg.FormatFreeContext(inFmtCtx) | |
| 136 | - | return fmt.Errorf("open input: %d", ret) | |
| 100 | + | return err | |
| 137 | 101 | } | |
| 138 | - | defer ffmpeg.FormatCloseInput(unsafe.Pointer(&inFmtCtx)) | |
| 102 | + | defer closeInput() | |
| 139 | 103 | ||
| 140 | 104 | // --- Step 4: Find stream info --- | |
| 141 | 105 | if ret := ffmpeg.FormatFindStreamInfo(inFmtCtx, nil); ret < 0 { | |
| 142 | 106 | return fmt.Errorf("find stream info: %d", ret) | |
| 143 | 107 | } | |
| 144 | 108 | ||
| 145 | - | // --- Step 5: Find best video and audio streams --- | |
| 146 | - | videoIdx := ffmpeg.FindBestStream(inFmtCtx, int32(ffmpeg.AVMEDIA_TYPE_VIDEO), -1, -1, nil, 0) | |
| 147 | - | audioIdx := ffmpeg.FindBestStream(inFmtCtx, int32(ffmpeg.AVMEDIA_TYPE_AUDIO), -1, -1, nil, 0) | |
| 148 | - | ||
| 149 | - | if videoIdx < 0 { | |
| 150 | - | return errors.New("no video stream found in source") | |
| 109 | + | // --- Step 5: Select input video/audio streams --- | |
| 110 | + | videoIdx, audioIdx, err := p.selectInputStreams(inFmtCtx) | |
| 111 | + | if err != nil { | |
| 112 | + | return err | |
| 151 | 113 | } | |
| 152 | 114 | ||
| 153 | - | videoStream := ffmpeg.FmtCtxStream(inFmtCtx, int(videoIdx)) | |
| 154 | - | videoStreamWrap := (*ffmpeg.Stream)(videoStream) | |
| 115 | + | videoStream := ffmpeg.FmtCtxStream(inFmtCtx, videoIdx) | |
| 116 | + | videoStreamWrap := ffmpeg.WrapStream(videoStream) | |
| 155 | 117 | videoCodecPar := videoStreamWrap.Codecpar() | |
| 156 | 118 | ||
| 157 | 119 | // --- Step 6: Open video decoder --- | |
| @@ -207,7 +169,7 @@ func (p *pipeline) doRun(ctx context.Context) error { | |||
| 207 | 169 | if outVideoStream == nil { | |
| 208 | 170 | return errors.New("failed to create output video stream") | |
| 209 | 171 | } | |
| 210 | - | outVideoStreamWrap := (*ffmpeg.Stream)(outVideoStream) | |
| 172 | + | outVideoStreamWrap := ffmpeg.WrapStream(outVideoStream) | |
| 211 | 173 | outVideoIdx := int(outVideoStreamWrap.Index()) | |
| 212 | 174 | ||
| 213 | 175 | if ret := ffmpeg.CodecParametersFromContext(outVideoStreamWrap.Codecpar(), vidEncCtx); ret < 0 { | |
| @@ -217,8 +179,8 @@ func (p *pipeline) doRun(ctx context.Context) error { | |||
| 217 | 179 | // --- Step 11b: Add audio output stream (if audio present) --- | |
| 218 | 180 | var audioTx *audioTranscoder | |
| 219 | 181 | if audioIdx >= 0 { | |
| 220 | - | audioStream := ffmpeg.FmtCtxStream(inFmtCtx, int(audioIdx)) | |
| 221 | - | audioStreamWrap := (*ffmpeg.Stream)(audioStream) | |
| 182 | + | audioStream := ffmpeg.FmtCtxStream(inFmtCtx, audioIdx) | |
| 183 | + | audioStreamWrap := ffmpeg.WrapStream(audioStream) | |
| 222 | 184 | audioCodecPar := audioStreamWrap.Codecpar() | |
| 223 | 185 | ||
| 224 | 186 | var txErr error | |
| @@ -271,7 +233,7 @@ func (p *pipeline) doRun(ctx context.Context) error { | |||
| 271 | 233 | break | |
| 272 | 234 | } | |
| 273 | 235 | ||
| 274 | - | streamIdx := pktStreamIndex(pkt) | |
| 236 | + | streamIdx := int(pktStreamIndex(pkt)) | |
| 275 | 237 | ||
| 276 | 238 | switch { | |
| 277 | 239 | case streamIdx == videoIdx: | |
| @@ -311,7 +273,7 @@ func (p *pipeline) doRun(ctx context.Context) error { | |||
| 311 | 273 | } | |
| 312 | 274 | pktSetStreamIndex(encPkt, int32(outVideoIdx)) | |
| 313 | 275 | outVidStream := ffmpeg.FmtCtxStream(outFmtCtx, outVideoIdx) | |
| 314 | - | outVidStreamWrap := (*ffmpeg.Stream)(outVidStream) | |
| 276 | + | outVidStreamWrap := ffmpeg.WrapStream(outVidStream) | |
| 315 | 277 | ffmpeg.PacketRescaleTs(encPkt, ffmpeg.CodecCtxTimeBase(vidEncCtx), outVidStreamWrap.TimeBase()) | |
| 316 | 278 | ffmpeg.InterleavedWriteFrame(outFmtCtx, encPkt) | |
| 317 | 279 | ffmpeg.PacketUnref(encPkt) | |
| @@ -330,6 +292,235 @@ func (p *pipeline) doRun(ctx context.Context) error { | |||
| 330 | 292 | return nil | |
| 331 | 293 | } | |
| 332 | 294 | ||
| 295 | + | func (p *pipeline) selectInputStreams(inFmtCtx unsafe.Pointer) (videoIdx int, audioIdx int, err error) { | |
| 296 | + | videoIdx = -1 | |
| 297 | + | audioIdx = -1 | |
| 298 | + | bestVideoPixels := int64(-1) | |
| 299 | + | ||
| 300 | + | streamCount := int(ffmpeg.FmtCtxNbStreams(inFmtCtx)) | |
| 301 | + | for i := 0; i < streamCount; i++ { | |
| 302 | + | stream := ffmpeg.FmtCtxStream(inFmtCtx, i) | |
| 303 | + | if stream == nil { | |
| 304 | + | continue | |
| 305 | + | } | |
| 306 | + | streamWrap := ffmpeg.WrapStream(stream) | |
| 307 | + | codecPar := streamWrap.Codecpar() | |
| 308 | + | if codecPar == nil { | |
| 309 | + | continue | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | switch ffmpeg.CodecParCodecType(codecPar) { | |
| 313 | + | case int32(ffmpeg.AVMEDIA_TYPE_VIDEO): | |
| 314 | + | width := int64(ffmpeg.CodecParWidth(codecPar)) | |
| 315 | + | height := int64(ffmpeg.CodecParHeight(codecPar)) | |
| 316 | + | pixels := width * height | |
| 317 | + | if videoIdx == -1 || pixels > bestVideoPixels { | |
| 318 | + | videoIdx = i | |
| 319 | + | bestVideoPixels = pixels | |
| 320 | + | } | |
| 321 | + | case int32(ffmpeg.AVMEDIA_TYPE_AUDIO): | |
| 322 | + | if audioIdx == -1 { | |
| 323 | + | audioIdx = i | |
| 324 | + | } | |
| 325 | + | } | |
| 326 | + | } | |
| 327 | + | ||
| 328 | + | if videoIdx < 0 { | |
| 329 | + | return -1, audioIdx, errors.New("no video stream found in source") | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | videoCodecPar := ffmpeg.WrapStream(ffmpeg.FmtCtxStream(inFmtCtx, videoIdx)).Codecpar() | |
| 333 | + | log := p.logger.With(). | |
| 334 | + | Str("session_id", p.sessionID). | |
| 335 | + | Str("source_url", p.sourceURL). | |
| 336 | + | Int("video_stream_index", videoIdx). | |
| 337 | + | Int32("video_codec_id", ffmpeg.CodecParCodecID(videoCodecPar)). | |
| 338 | + | Int32("video_width", ffmpeg.CodecParWidth(videoCodecPar)). | |
| 339 | + | Int32("video_height", ffmpeg.CodecParHeight(videoCodecPar)). | |
| 340 | + | Logger() | |
| 341 | + | if audioIdx >= 0 { | |
| 342 | + | audioCodecPar := ffmpeg.WrapStream(ffmpeg.FmtCtxStream(inFmtCtx, audioIdx)).Codecpar() | |
| 343 | + | log.Info(). | |
| 344 | + | Int("audio_stream_index", audioIdx). | |
| 345 | + | Int32("audio_codec_id", ffmpeg.CodecParCodecID(audioCodecPar)). | |
| 346 | + | Msg("transcode pipeline selected input streams") | |
| 347 | + | } else { | |
| 348 | + | log.Info().Msg("transcode pipeline selected input streams") | |
| 349 | + | } | |
| 350 | + | ||
| 351 | + | return videoIdx, audioIdx, nil | |
| 352 | + | } | |
| 353 | + | ||
| 354 | + | func (p *pipeline) openInputFormatContext(ctx context.Context) (unsafe.Pointer, func(), error) { | |
| 355 | + | if IsStreamingManifestURL(p.sourceURL) { | |
| 356 | + | return p.openManifestInputContext() | |
| 357 | + | } | |
| 358 | + | return p.openCustomIOInputContext(ctx) | |
| 359 | + | } | |
| 360 | + | ||
| 361 | + | func (p *pipeline) openCustomIOInputContext(ctx context.Context) (unsafe.Pointer, func(), error) { | |
| 362 | + | req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.sourceURL, nil) | |
| 363 | + | if err != nil { | |
| 364 | + | return nil, nil, fmt.Errorf("create request: %w", err) | |
| 365 | + | } | |
| 366 | + | for k, v := range p.headers { | |
| 367 | + | req.Header.Set(k, v) | |
| 368 | + | } | |
| 369 | + | ||
| 370 | + | resp, err := httpClient.Do(req) | |
| 371 | + | if err != nil { | |
| 372 | + | return nil, nil, fmt.Errorf("fetch source: %w", err) | |
| 373 | + | } | |
| 374 | + | ||
| 375 | + | if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent { | |
| 376 | + | resp.Body.Close() | |
| 377 | + | return nil, nil, fmt.Errorf("source returned HTTP %d", resp.StatusCode) | |
| 378 | + | } | |
| 379 | + | ||
| 380 | + | body := resp.Body | |
| 381 | + | readFn := func(_ unsafe.Pointer, buf unsafe.Pointer, bufSize int32) int32 { | |
| 382 | + | if bufSize <= 0 { | |
| 383 | + | return -1 | |
| 384 | + | } | |
| 385 | + | goBuf := unsafe.Slice((*byte)(buf), int(bufSize)) | |
| 386 | + | n, readErr := body.Read(goBuf) | |
| 387 | + | if n > 0 { | |
| 388 | + | return int32(n) | |
| 389 | + | } | |
| 390 | + | if readErr != nil { | |
| 391 | + | // Return AVERROR_EOF for EOF, generic error otherwise. | |
| 392 | + | return averrorEOF | |
| 393 | + | } | |
| 394 | + | return -1 | |
| 395 | + | } | |
| 396 | + | p.readCb = purego.NewCallback(readFn) | |
| 397 | + | ||
| 398 | + | readAVIO := ffmpeg.AvioAllocContext(avioBufSize, false, nil, p.readCb, 0, 0) | |
| 399 | + | if readAVIO == nil { | |
| 400 | + | resp.Body.Close() | |
| 401 | + | return nil, nil, errors.New("failed to allocate read AVIO context") | |
| 402 | + | } | |
| 403 | + | ||
| 404 | + | inFmtCtx := ffmpeg.FormatAllocContext() | |
| 405 | + | if inFmtCtx == nil { | |
| 406 | + | ffmpeg.AvioContextFree(unsafe.Pointer(&readAVIO)) | |
| 407 | + | resp.Body.Close() | |
| 408 | + | return nil, nil, errors.New("failed to allocate input format context") | |
| 409 | + | } | |
| 410 | + | ||
| 411 | + | ffmpeg.FmtCtxSetPB(inFmtCtx, readAVIO) | |
| 412 | + | ffmpeg.FmtCtxSetFlags(inFmtCtx, ffmpeg.FmtCtxFlags(inFmtCtx)|ffmpeg.AVFMT_FLAG_CUSTOM_IO) | |
| 413 | + | ||
| 414 | + | if ret := ffmpeg.FormatOpenInput(unsafe.Pointer(&inFmtCtx), p.sourceURL, nil, nil); ret < 0 { | |
| 415 | + | ffmpeg.AvioContextFree(unsafe.Pointer(&readAVIO)) | |
| 416 | + | resp.Body.Close() | |
| 417 | + | ffmpeg.FormatFreeContext(inFmtCtx) | |
| 418 | + | return nil, nil, fmt.Errorf("open input: %d", ret) | |
| 419 | + | } | |
| 420 | + | ||
| 421 | + | cleanup := func() { | |
| 422 | + | ffmpeg.FormatCloseInput(unsafe.Pointer(&inFmtCtx)) | |
| 423 | + | ffmpeg.AvioContextFree(unsafe.Pointer(&readAVIO)) | |
| 424 | + | resp.Body.Close() | |
| 425 | + | } | |
| 426 | + | return inFmtCtx, cleanup, nil | |
| 427 | + | } | |
| 428 | + | ||
| 429 | + | func (p *pipeline) openManifestInputContext() (unsafe.Pointer, func(), error) { | |
| 430 | + | inFmtCtx := ffmpeg.FormatAllocContext() | |
| 431 | + | if inFmtCtx == nil { | |
| 432 | + | return nil, nil, errors.New("failed to allocate input format context") | |
| 433 | + | } | |
| 434 | + | ||
| 435 | + | var opts unsafe.Pointer | |
| 436 | + | defer freeInputOptions(&opts) | |
| 437 | + | ||
| 438 | + | if err := setInputOption(&opts, "protocol_whitelist", "file,http,https,tcp,tls,crypto"); err != nil { | |
| 439 | + | ffmpeg.FormatFreeContext(inFmtCtx) | |
| 440 | + | return nil, nil, err | |
| 441 | + | } | |
| 442 | + | if userAgent := p.headers["User-Agent"]; userAgent != "" { | |
| 443 | + | if err := setInputOption(&opts, "user_agent", userAgent); err != nil { | |
| 444 | + | ffmpeg.FormatFreeContext(inFmtCtx) | |
| 445 | + | return nil, nil, err | |
| 446 | + | } | |
| 447 | + | } | |
| 448 | + | if referer := p.headers["Referer"]; referer != "" { | |
| 449 | + | if err := setInputOption(&opts, "referer", referer); err != nil { | |
| 450 | + | ffmpeg.FormatFreeContext(inFmtCtx) | |
| 451 | + | return nil, nil, err | |
| 452 | + | } | |
| 453 | + | } | |
| 454 | + | ||
| 455 | + | headerLines := formatHTTPHeaderOptions(p.headers) | |
| 456 | + | if headerLines != "" { | |
| 457 | + | if err := setInputOption(&opts, "headers", headerLines); err != nil { | |
| 458 | + | ffmpeg.FormatFreeContext(inFmtCtx) | |
| 459 | + | return nil, nil, err | |
| 460 | + | } | |
| 461 | + | } | |
| 462 | + | ||
| 463 | + | if ret := ffmpeg.FormatOpenInput(unsafe.Pointer(&inFmtCtx), p.sourceURL, nil, unsafe.Pointer(&opts)); ret < 0 { | |
| 464 | + | ffmpeg.FormatFreeContext(inFmtCtx) | |
| 465 | + | return nil, nil, fmt.Errorf("open manifest input: %d", ret) | |
| 466 | + | } | |
| 467 | + | ||
| 468 | + | return inFmtCtx, func() { | |
| 469 | + | ffmpeg.FormatCloseInput(unsafe.Pointer(&inFmtCtx)) | |
| 470 | + | }, nil | |
| 471 | + | } | |
| 472 | + | ||
| 473 | + | func freeInputOptions(opts *unsafe.Pointer) { | |
| 474 | + | if opts == nil || *opts == nil { | |
| 475 | + | return | |
| 476 | + | } | |
| 477 | + | // av_dict_free expects an AVDictionary**. Passing the dictionary pointer | |
| 478 | + | // itself corrupts memory and can crash inside libavutil during cleanup. | |
| 479 | + | ffmpeg.DictFree(unsafe.Pointer(opts)) | |
| 480 | + | *opts = nil | |
| 481 | + | } | |
| 482 | + | ||
| 483 | + | func setInputOption(opts *unsafe.Pointer, key, value string) error { | |
| 484 | + | if value == "" { | |
| 485 | + | return nil | |
| 486 | + | } | |
| 487 | + | if ret := ffmpeg.DictSet(unsafe.Pointer(opts), key, value, 0); ret < 0 { | |
| 488 | + | return fmt.Errorf("set input option %q: %d", key, ret) | |
| 489 | + | } | |
| 490 | + | return nil | |
| 491 | + | } | |
| 492 | + | ||
| 493 | + | func formatHTTPHeaderOptions(headers map[string]string) string { | |
| 494 | + | if len(headers) == 0 { | |
| 495 | + | return "" | |
| 496 | + | } | |
| 497 | + | ||
| 498 | + | keys := make([]string, 0, len(headers)) | |
| 499 | + | for key, value := range headers { | |
| 500 | + | if strings.TrimSpace(value) == "" { | |
| 501 | + | continue | |
| 502 | + | } | |
| 503 | + | if strings.EqualFold(key, "User-Agent") || strings.EqualFold(key, "Referer") { | |
| 504 | + | continue | |
| 505 | + | } | |
| 506 | + | keys = append(keys, key) | |
| 507 | + | } | |
| 508 | + | if len(keys) == 0 { | |
| 509 | + | return "" | |
| 510 | + | } | |
| 511 | + | ||
| 512 | + | sort.Strings(keys) | |
| 513 | + | ||
| 514 | + | var builder strings.Builder | |
| 515 | + | for _, key := range keys { | |
| 516 | + | builder.WriteString(key) | |
| 517 | + | builder.WriteString(": ") | |
| 518 | + | builder.WriteString(headers[key]) | |
| 519 | + | builder.WriteString("\r\n") | |
| 520 | + | } | |
| 521 | + | return builder.String() | |
| 522 | + | } | |
| 523 | + | ||
| 333 | 524 | // openVideoDecoder creates and opens a decoder context for the video stream. | |
| 334 | 525 | // It prefers hardware decoders (h264_vaapi, h264_cuvid) based on hwCaps, | |
| 335 | 526 | // falling back to the generic software decoder. | |
| @@ -394,28 +585,50 @@ func (p *pipeline) openVideoEncoder(decCtx unsafe.Pointer) (hwDeviceCtx unsafe.P | |||
| 394 | 585 | return nil, nil, fmt.Errorf("create HW device: %w", hwErr) | |
| 395 | 586 | } | |
| 396 | 587 | ||
| 397 | - | // Find the first working encoder. | |
| 398 | - | var enc unsafe.Pointer | |
| 399 | - | var encoderName string | |
| 588 | + | width := ffmpeg.CodecCtxWidth(decCtx) | |
| 589 | + | height := ffmpeg.CodecCtxHeight(decCtx) | |
| 590 | + | ||
| 591 | + | var lastErr error | |
| 400 | 592 | for _, name := range p.hwCaps.Encoders { | |
| 401 | - | enc = ffmpeg.CodecFindEncoderByName(name) | |
| 402 | - | if enc != nil { | |
| 403 | - | encoderName = name | |
| 404 | - | break | |
| 593 | + | enc := ffmpeg.CodecFindEncoderByName(name) | |
| 594 | + | if enc == nil { | |
| 595 | + | continue | |
| 596 | + | } | |
| 597 | + | ||
| 598 | + | encCtx, err = p.tryOpenVideoEncoder(decCtx, hwDeviceCtx, name, enc) | |
| 599 | + | if err == nil { | |
| 600 | + | p.logger.Info(). | |
| 601 | + | Str("session_id", p.sessionID). | |
| 602 | + | Str("source_url", p.sourceURL). | |
| 603 | + | Str("encoder", name). | |
| 604 | + | Str("api", p.hwCaps.API). | |
| 605 | + | Int32("width", width). | |
| 606 | + | Int32("height", height). | |
| 607 | + | Msg("transcode pipeline selected video encoder") | |
| 608 | + | return hwDeviceCtx, encCtx, nil | |
| 405 | 609 | } | |
| 610 | + | lastErr = err | |
| 611 | + | p.logger.Warn(). | |
| 612 | + | Str("session_id", p.sessionID). | |
| 613 | + | Str("source_url", p.sourceURL). | |
| 614 | + | Str("encoder", name). | |
| 615 | + | Err(err). | |
| 616 | + | Msg("transcode pipeline encoder candidate failed") | |
| 406 | 617 | } | |
| 407 | - | if enc == nil { | |
| 408 | - | ffmpeg.BufferUnref(&hwDeviceCtx) | |
| 409 | - | return nil, nil, errors.New("no usable encoder codec found") | |
| 618 | + | ||
| 619 | + | ffmpeg.BufferUnref(&hwDeviceCtx) | |
| 620 | + | if lastErr != nil { | |
| 621 | + | return nil, nil, fmt.Errorf("no usable encoder codec found: %w", lastErr) | |
| 410 | 622 | } | |
| 623 | + | return nil, nil, errors.New("no usable encoder codec found") | |
| 624 | + | } | |
| 411 | 625 | ||
| 412 | - | encCtx = ffmpeg.CodecAllocContext3(enc) | |
| 626 | + | func (p *pipeline) tryOpenVideoEncoder(decCtx, hwDeviceCtx unsafe.Pointer, encoderName string, enc unsafe.Pointer) (unsafe.Pointer, error) { | |
| 627 | + | encCtx := ffmpeg.CodecAllocContext3(enc) | |
| 413 | 628 | if encCtx == nil { | |
| 414 | - | ffmpeg.BufferUnref(&hwDeviceCtx) | |
| 415 | - | return nil, nil, errors.New("failed to allocate video encoder context") | |
| 629 | + | return nil, errors.New("failed to allocate video encoder context") | |
| 416 | 630 | } | |
| 417 | 631 | ||
| 418 | - | // Copy dimensions and framerate from decoder. | |
| 419 | 632 | width := ffmpeg.CodecCtxWidth(decCtx) | |
| 420 | 633 | height := ffmpeg.CodecCtxHeight(decCtx) | |
| 421 | 634 | ffmpeg.CodecCtxSetWidth(encCtx, width) | |
| @@ -428,77 +641,50 @@ func (p *pipeline) openVideoEncoder(decCtx unsafe.Pointer) (hwDeviceCtx unsafe.P | |||
| 428 | 641 | ffmpeg.CodecCtxSetFramerate(encCtx, framerate) | |
| 429 | 642 | ffmpeg.CodecCtxSetTimeBase(encCtx, ffmpeg.AVRational{Num: framerate.Den, Den: framerate.Num}) | |
| 430 | 643 | ||
| 431 | - | // Set pixel format based on the API. | |
| 432 | 644 | if p.hwCaps.API == "vaapi" { | |
| 433 | 645 | ffmpeg.CodecCtxSetPixFmt(encCtx, int32(ffmpeg.PixelFormatPixFmtVaapi)) | |
| 434 | 646 | } else { | |
| 435 | - | // CUDA/NVENC uses NV12. | |
| 436 | 647 | ffmpeg.CodecCtxSetPixFmt(encCtx, int32(ffmpeg.PixelFormatPixFmtNv12)) | |
| 437 | 648 | } | |
| 438 | 649 | ||
| 439 | - | // Set GOP size to 2 seconds worth of frames. | |
| 440 | 650 | gopSize := int32(framerate.Num / framerate.Den * 2) | |
| 441 | 651 | if gopSize <= 0 { | |
| 442 | 652 | gopSize = 60 | |
| 443 | 653 | } | |
| 444 | 654 | codecCtxSetGopSize(encCtx, gopSize) | |
| 445 | - | ||
| 446 | - | // Set quality/bitrate based on quality parameter. | |
| 447 | 655 | p.applyQualityPreset(encCtx, encoderName, width, height) | |
| 448 | 656 | ||
| 449 | - | // Attach HW device context. Use BufferRef to create a new reference | |
| 450 | - | // since the encoder will take ownership of one ref. | |
| 451 | 657 | hwDeviceRef := ffmpeg.BufferRef(hwDeviceCtx) | |
| 452 | 658 | if hwDeviceRef == nil { | |
| 453 | 659 | ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx)) | |
| 454 | - | ffmpeg.BufferUnref(&hwDeviceCtx) | |
| 455 | - | return nil, nil, errors.New("failed to ref HW device context for encoder") | |
| 660 | + | return nil, errors.New("failed to ref HW device context for encoder") | |
| 456 | 661 | } | |
| 457 | 662 | ffmpeg.CodecCtxSetHwDeviceCtx(encCtx, hwDeviceRef) | |
| 458 | 663 | ||
| 459 | - | // For VAAPI encoders, we need a HW frames context. | |
| 460 | 664 | if p.hwCaps.API == "vaapi" { | |
| 461 | 665 | framesRef := ffmpeg.HWFrameCtxAlloc(hwDeviceCtx) | |
| 462 | 666 | if framesRef == nil { | |
| 463 | 667 | ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx)) | |
| 464 | - | ffmpeg.BufferUnref(&hwDeviceCtx) | |
| 465 | - | return nil, nil, errors.New("failed to allocate HW frames context") | |
| 466 | - | } | |
| 467 | - | ||
| 468 | - | // Configure the frames context. The HWFramesContext struct starts | |
| 469 | - | // with an AVBufferRef header. The actual AVHWFramesContext data is | |
| 470 | - | // accessible after dereferencing. We configure via av_opt or direct | |
| 471 | - | // offset access. For simplicity, use the known offsets: | |
| 472 | - | // AVHWFramesContext (within the AVBufferRef->data): | |
| 473 | - | // format (sw_format) at offset 8, width at 12, height at 16, | |
| 474 | - | // sw_format at 20, initial_pool_size at 24. | |
| 475 | - | // Actually, the buffer ref data pointer leads to AVHWFramesContext. | |
| 476 | - | // The fields are: AVClass* (8), AVBufferRef* device_ref (8), | |
| 477 | - | // AVBufferPool* pool (8), int initial_pool_size (4), | |
| 478 | - | // enum AVPixelFormat format (4), enum AVPixelFormat sw_format (4), | |
| 479 | - | // int width (4), int height (4). | |
| 480 | - | // So from the data pointer: | |
| 481 | - | // offset 24: initial_pool_size (int32) | |
| 482 | - | // offset 28: format (int32) — the HW pixel format | |
| 483 | - | // offset 32: sw_format (int32) — the SW pixel format | |
| 484 | - | // offset 36: width (int32) | |
| 485 | - | // offset 40: height (int32) | |
| 486 | - | // | |
| 487 | - | // We access data via the AVBufferRef at offset 0 (data field at offset 0). | |
| 488 | - | hwFramesData := *(*unsafe.Pointer)(framesRef) // AVBufferRef->data | |
| 489 | - | if hwFramesData != nil { | |
| 490 | - | *(*int32)(unsafe.Add(hwFramesData, 24)) = 20 // initial_pool_size | |
| 491 | - | *(*int32)(unsafe.Add(hwFramesData, 28)) = int32(ffmpeg.PixelFormatPixFmtVaapi) | |
| 492 | - | *(*int32)(unsafe.Add(hwFramesData, 32)) = int32(ffmpeg.PixelFormatPixFmtNv12) // sw_format | |
| 493 | - | *(*int32)(unsafe.Add(hwFramesData, 36)) = width | |
| 494 | - | *(*int32)(unsafe.Add(hwFramesData, 40)) = height | |
| 668 | + | return nil, errors.New("failed to allocate HW frames context") | |
| 495 | 669 | } | |
| 496 | 670 | ||
| 671 | + | hwFramesData := ffmpeg.BufferRefData(framesRef) | |
| 672 | + | if hwFramesData == nil { | |
| 673 | + | ffmpeg.BufferUnref(&framesRef) | |
| 674 | + | ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx)) | |
| 675 | + | return nil, errors.New("failed to access HW frames context payload") | |
| 676 | + | } | |
| 677 | + | ||
| 678 | + | ffmpeg.HWFramesCtxSetInitialPoolSize(hwFramesData, 20) | |
| 679 | + | ffmpeg.HWFramesCtxSetFormat(hwFramesData, int32(ffmpeg.PixelFormatPixFmtVaapi)) | |
| 680 | + | ffmpeg.HWFramesCtxSetSWFormat(hwFramesData, int32(ffmpeg.PixelFormatPixFmtNv12)) | |
| 681 | + | ffmpeg.HWFramesCtxSetWidth(hwFramesData, width) | |
| 682 | + | ffmpeg.HWFramesCtxSetHeight(hwFramesData, height) | |
| 683 | + | ||
| 497 | 684 | if ret := ffmpeg.HWFrameCtxInit(framesRef); ret < 0 { | |
| 498 | 685 | ffmpeg.BufferUnref(&framesRef) | |
| 499 | 686 | ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx)) | |
| 500 | - | ffmpeg.BufferUnref(&hwDeviceCtx) | |
| 501 | - | return nil, nil, fmt.Errorf("init HW frames context: %d", ret) | |
| 687 | + | return nil, fmt.Errorf("init HW frames context: %d", ret) | |
| 502 | 688 | } | |
| 503 | 689 | ||
| 504 | 690 | ffmpeg.CodecCtxSetHwFramesCtx(encCtx, framesRef) | |
| @@ -506,11 +692,10 @@ func (p *pipeline) openVideoEncoder(decCtx unsafe.Pointer) (hwDeviceCtx unsafe.P | |||
| 506 | 692 | ||
| 507 | 693 | if ret := ffmpeg.CodecOpen2(encCtx, enc, nil); ret < 0 { | |
| 508 | 694 | ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx)) | |
| 509 | - | ffmpeg.BufferUnref(&hwDeviceCtx) | |
| 510 | - | return nil, nil, fmt.Errorf("open video encoder %s: %d", encoderName, ret) | |
| 695 | + | return nil, fmt.Errorf("open video encoder %s: %d", encoderName, ret) | |
| 511 | 696 | } | |
| 512 | 697 | ||
| 513 | - | return hwDeviceCtx, encCtx, nil | |
| 698 | + | return encCtx, nil | |
| 514 | 699 | } | |
| 515 | 700 | ||
| 516 | 701 | // applyQualityPreset sets bitrate and encoder options based on the | |
| @@ -582,12 +767,18 @@ func (p *pipeline) transcodeVideoPacket(pkt, frame, encPkt, decCtx, encCtx, outF | |||
| 582 | 767 | // encodeAndWriteVideoFrame sends a decoded frame to the encoder and | |
| 583 | 768 | // writes any resulting packets to the output. | |
| 584 | 769 | func (p *pipeline) encodeAndWriteVideoFrame(frame, encPkt, encCtx, outFmtCtx unsafe.Pointer, inStreamWrap *ffmpeg.Stream, outVideoIdx int) error { | |
| 585 | - | // If the frame is in SW format and the encoder expects HW format, | |
| 586 | - | // we would need to upload. For now, send directly — the HW encoder | |
| 587 | - | // with hw_device_ctx set should handle format conversion internally | |
| 588 | - | // when the decoder outputs SW frames. | |
| 770 | + | encFrame := frame | |
| 771 | + | var cleanup func() | |
| 772 | + | if p.hwCaps.API == "vaapi" { | |
| 773 | + | var err error | |
| 774 | + | encFrame, cleanup, err = p.uploadVideoFrameToVAAPI(frame, encCtx) | |
| 775 | + | if err != nil { | |
| 776 | + | return err | |
| 777 | + | } | |
| 778 | + | defer cleanup() | |
| 779 | + | } | |
| 589 | 780 | ||
| 590 | - | ret := ffmpeg.CodecSendFrame(encCtx, frame) | |
| 781 | + | ret := ffmpeg.CodecSendFrame(encCtx, encFrame) | |
| 591 | 782 | if ret < 0 { | |
| 592 | 783 | return fmt.Errorf("video encode send frame: %d", ret) | |
| 593 | 784 | } | |
| @@ -601,7 +792,7 @@ func (p *pipeline) encodeAndWriteVideoFrame(frame, encPkt, encCtx, outFmtCtx uns | |||
| 601 | 792 | pktSetStreamIndex(encPkt, int32(outVideoIdx)) | |
| 602 | 793 | ||
| 603 | 794 | outVidStream := ffmpeg.FmtCtxStream(outFmtCtx, outVideoIdx) | |
| 604 | - | outVidStreamWrap := (*ffmpeg.Stream)(outVidStream) | |
| 795 | + | outVidStreamWrap := ffmpeg.WrapStream(outVidStream) | |
| 605 | 796 | ffmpeg.PacketRescaleTs(encPkt, ffmpeg.CodecCtxTimeBase(encCtx), outVidStreamWrap.TimeBase()) | |
| 606 | 797 | ||
| 607 | 798 | if wret := ffmpeg.InterleavedWriteFrame(outFmtCtx, encPkt); wret < 0 { | |
| @@ -614,5 +805,50 @@ func (p *pipeline) encodeAndWriteVideoFrame(frame, encPkt, encCtx, outFmtCtx uns | |||
| 614 | 805 | return nil | |
| 615 | 806 | } | |
| 616 | 807 | ||
| 808 | + | func (p *pipeline) uploadVideoFrameToVAAPI(frame, encCtx unsafe.Pointer) (unsafe.Pointer, func(), error) { | |
| 809 | + | if frameFormat(frame) == int32(ffmpeg.PixelFormatPixFmtVaapi) { | |
| 810 | + | return frame, func() {}, nil | |
| 811 | + | } | |
| 812 | + | ||
| 813 | + | hwFramesCtx := ffmpeg.CodecCtxHwFramesCtx(encCtx) | |
| 814 | + | if hwFramesCtx == nil { | |
| 815 | + | return nil, nil, errors.New("video encoder missing hw_frames_ctx") | |
| 816 | + | } | |
| 817 | + | ||
| 818 | + | hwFrame := ffmpeg.FrameAlloc() | |
| 819 | + | if hwFrame == nil { | |
| 820 | + | return nil, nil, errors.New("failed to allocate VAAPI frame") | |
| 821 | + | } | |
| 822 | + | ||
| 823 | + | cleanup := func() { | |
| 824 | + | if hwFrame != nil { | |
| 825 | + | ffmpeg.FrameFree(unsafe.Pointer(&hwFrame)) | |
| 826 | + | } | |
| 827 | + | } | |
| 828 | + | ||
| 829 | + | frameSetFormat(hwFrame, int32(ffmpeg.PixelFormatPixFmtVaapi)) | |
| 830 | + | frameSetWidth(hwFrame, frameWidth(frame)) | |
| 831 | + | frameSetHeight(hwFrame, frameHeight(frame)) | |
| 832 | + | frameSetPts(hwFrame, framePts(frame)) | |
| 833 | + | ||
| 834 | + | hwFramesRef := ffmpeg.BufferRef(hwFramesCtx) | |
| 835 | + | if hwFramesRef == nil { | |
| 836 | + | cleanup() | |
| 837 | + | return nil, nil, errors.New("failed to ref encoder hw frames context") | |
| 838 | + | } | |
| 839 | + | frameSetHWFramesCtx(hwFrame, hwFramesRef) | |
| 840 | + | ||
| 841 | + | if ret := ffmpeg.HWFrameGetBuffer(hwFramesCtx, hwFrame, 0); ret < 0 { | |
| 842 | + | cleanup() | |
| 843 | + | return nil, nil, fmt.Errorf("allocate VAAPI frame buffer: %d", ret) | |
| 844 | + | } | |
| 845 | + | if ret := ffmpeg.HWFrameTransferData(hwFrame, frame, 0); ret < 0 { | |
| 846 | + | cleanup() | |
| 847 | + | return nil, nil, fmt.Errorf("upload frame to VAAPI: %d", ret) | |
| 848 | + | } | |
| 849 | + | ||
| 850 | + | return hwFrame, cleanup, nil | |
| 851 | + | } | |
| 852 | + | ||
| 617 | 853 | // averrorEOF is the AVERROR_EOF constant. | |
| 618 | 854 | const averrorEOF = -('E' | 'O'<<8 | 'F'<<16 | ' '<<24) | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | "io" | 7 | "io" | ||
| 8 | "net/http" | 8 | "net/http" | ||
| 9 | + | "sort" | |||
| 9 | "strings" | 10 | "strings" | ||
| 10 | "unsafe" | 11 | "unsafe" | ||
| 11 | 12 | ||||
| @@ -29,6 +30,7 @@ const avioBufSize = 32 * 1024 | |||||
| 29 | // Opus via libopus, and muxes the result into WebM written to an | 30 | // Opus via libopus, and muxes the result into WebM written to an | ||
| 30 | // io.PipeWriter. | 31 | // io.PipeWriter. | ||
| 31 | type pipeline struct { | 32 | type pipeline struct { | ||
| 33 | + | sessionID string | |||
| 32 | hwCaps port.HWCapabilities | 34 | hwCaps port.HWCapabilities | ||
| 33 | sourceURL string | 35 | sourceURL string | ||
| 34 | headers map[string]string | 36 | headers map[string]string | ||
| @@ -53,8 +55,9 @@ var httpClient = &http.Client{ | |||||
| 53 | }, | 55 | }, | ||
| 54 | } | 56 | } | ||
| 55 | 57 | ||||
| 56 | - | func newPipeline(hwCaps port.HWCapabilities, sourceURL string, headers map[string]string, quality string, pw *io.PipeWriter, logger zerolog.Logger) *pipeline { | 58 | + | func newPipeline(sessionID string, hwCaps port.HWCapabilities, sourceURL string, headers map[string]string, quality string, pw *io.PipeWriter, logger zerolog.Logger) *pipeline { |
| 57 | return &pipeline{ | 59 | return &pipeline{ | ||
| 60 | + | sessionID: sessionID, | |||
| 58 | hwCaps: hwCaps, | 61 | hwCaps: hwCaps, | ||
| 59 | sourceURL: sourceURL, | 62 | sourceURL: sourceURL, | ||
| 60 | headers: headers, | 63 | headers: headers, | ||
| @@ -69,8 +72,21 @@ func newPipeline(hwCaps port.HWCapabilities, sourceURL string, headers map[strin | |||||
| 69 | func (p *pipeline) run(ctx context.Context) { | 72 | func (p *pipeline) run(ctx context.Context) { | ||
| 70 | err := p.doRun(ctx) | 73 | err := p.doRun(ctx) | ||
| 71 | if err != nil { | 74 | if err != nil { | ||
| 75 | + | log := p.logger.With(). | |||
| 76 | + | Str("session_id", p.sessionID). | |||
| 77 | + | Str("source_url", p.sourceURL). | |||
| 78 | + | Logger() | |||
| 79 | + | if errors.Is(err, context.Canceled) { | |||
| 80 | + | log.Debug().Err(err).Msg("transcode pipeline cancelled") | |||
| 81 | + | } else { | |||
| 82 | + | log.Error().Err(err).Msg("transcode pipeline failed") | |||
| 83 | + | } | |||
| 72 | p.pw.CloseWithError(err) | 84 | p.pw.CloseWithError(err) | ||
| 73 | } else { | 85 | } else { | ||
| 86 | + | p.logger.Debug(). | |||
| 87 | + | Str("session_id", p.sessionID). | |||
| 88 | + | Str("source_url", p.sourceURL). | |||
| 89 | + | Msg("transcode pipeline completed") | |||
| 74 | p.pw.Close() | 90 | p.pw.Close() | ||
| 75 | } | 91 | } | ||
| 76 | } | 92 | } | ||
| @@ -78,80 +94,26 @@ func (p *pipeline) run(ctx context.Context) { | |||||
| 78 | // doRun contains the actual pipeline logic. Errors are returned, and | 94 | // doRun contains the actual pipeline logic. Errors are returned, and | ||
| 79 | // the caller (run) closes the pipe writer accordingly. | 95 | // the caller (run) closes the pipe writer accordingly. | ||
| 80 | func (p *pipeline) doRun(ctx context.Context) error { | 96 | func (p *pipeline) doRun(ctx context.Context) error { | ||
| 81 | - | // --- Step 1: HTTP GET the source --- | 97 | + | // --- Step 1-3: Open the input using the appropriate strategy --- |
| 82 | - | req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.sourceURL, nil) | 98 | + | inFmtCtx, closeInput, err := p.openInputFormatContext(ctx) |
| 83 | - | if err != nil { | |||
| 84 | - | return fmt.Errorf("create request: %w", err) | |||
| 85 | - | } | |||
| 86 | - | for k, v := range p.headers { | |||
| 87 | - | req.Header.Set(k, v) | |||
| 88 | - | } | |||
| 89 | - | ||||
| 90 | - | resp, err := httpClient.Do(req) | |||
| 91 | if err != nil { | 99 | if err != nil { | ||
| 92 | - | return fmt.Errorf("fetch source: %w", err) | 100 | + | return err |
| 93 | - | } | |||
| 94 | - | defer resp.Body.Close() | |||
| 95 | - | ||||
| 96 | - | if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent { | |||
| 97 | - | return fmt.Errorf("source returned HTTP %d", resp.StatusCode) | |||
| 98 | - | } | |||
| 99 | - | ||||
| 100 | - | // --- Step 2: Create read AVIO callback from HTTP body --- | |||
| 101 | - | body := resp.Body | |||
| 102 | - | readFn := func(_ unsafe.Pointer, buf unsafe.Pointer, bufSize int32) int32 { | |||
| 103 | - | if bufSize <= 0 { | |||
| 104 | - | return -1 | |||
| 105 | - | } | |||
| 106 | - | goBuf := unsafe.Slice((*byte)(buf), int(bufSize)) | |||
| 107 | - | n, readErr := body.Read(goBuf) | |||
| 108 | - | if n > 0 { | |||
| 109 | - | return int32(n) | |||
| 110 | - | } | |||
| 111 | - | if readErr != nil { | |||
| 112 | - | // Return AVERROR_EOF for EOF, generic error otherwise. | |||
| 113 | - | return averrorEOF | |||
| 114 | - | } | |||
| 115 | - | return -1 | |||
| 116 | - | } | |||
| 117 | - | p.readCb = purego.NewCallback(readFn) | |||
| 118 | - | ||||
| 119 | - | readAVIO := ffmpeg.AvioAllocContext(avioBufSize, false, nil, p.readCb, 0, 0) | |||
| 120 | - | if readAVIO == nil { | |||
| 121 | - | return errors.New("failed to allocate read AVIO context") | |||
| 122 | - | } | |||
| 123 | - | defer ffmpeg.AvioContextFree(unsafe.Pointer(&readAVIO)) | |||
| 124 | - | ||||
| 125 | - | // --- Step 3: Open input format context with custom AVIO --- | |||
| 126 | - | inFmtCtx := ffmpeg.FormatAllocContext() | |||
| 127 | - | if inFmtCtx == nil { | |||
| 128 | - | return errors.New("failed to allocate input format context") | |||
| 129 | - | } | |||
| 130 | - | ||||
| 131 | - | ffmpeg.FmtCtxSetPB(inFmtCtx, readAVIO) | |||
| 132 | - | ffmpeg.FmtCtxSetFlags(inFmtCtx, ffmpeg.FmtCtxFlags(inFmtCtx)|ffmpeg.AVFMT_FLAG_CUSTOM_IO) | |||
| 133 | - | ||||
| 134 | - | if ret := ffmpeg.FormatOpenInput(unsafe.Pointer(&inFmtCtx), "", nil, nil); ret < 0 { | |||
| 135 | - | ffmpeg.FormatFreeContext(inFmtCtx) | |||
| 136 | - | return fmt.Errorf("open input: %d", ret) | |||
| 137 | } | 101 | } | ||
| 138 | - | defer ffmpeg.FormatCloseInput(unsafe.Pointer(&inFmtCtx)) | 102 | + | defer closeInput() |
| 139 | 103 | ||||
| 140 | // --- Step 4: Find stream info --- | 104 | // --- Step 4: Find stream info --- | ||
| 141 | if ret := ffmpeg.FormatFindStreamInfo(inFmtCtx, nil); ret < 0 { | 105 | if ret := ffmpeg.FormatFindStreamInfo(inFmtCtx, nil); ret < 0 { | ||
| 142 | return fmt.Errorf("find stream info: %d", ret) | 106 | return fmt.Errorf("find stream info: %d", ret) | ||
| 143 | } | 107 | } | ||
| 144 | 108 | ||||
| 145 | - | // --- Step 5: Find best video and audio streams --- | 109 | + | // --- Step 5: Select input video/audio streams --- |
| 146 | - | videoIdx := ffmpeg.FindBestStream(inFmtCtx, int32(ffmpeg.AVMEDIA_TYPE_VIDEO), -1, -1, nil, 0) | 110 | + | videoIdx, audioIdx, err := p.selectInputStreams(inFmtCtx) |
| 147 | - | audioIdx := ffmpeg.FindBestStream(inFmtCtx, int32(ffmpeg.AVMEDIA_TYPE_AUDIO), -1, -1, nil, 0) | 111 | + | if err != nil { |
| 148 | - | 112 | + | return err | |
| 149 | - | if videoIdx < 0 { | |||
| 150 | - | return errors.New("no video stream found in source") | |||
| 151 | } | 113 | } | ||
| 152 | 114 | ||||
| 153 | - | videoStream := ffmpeg.FmtCtxStream(inFmtCtx, int(videoIdx)) | 115 | + | videoStream := ffmpeg.FmtCtxStream(inFmtCtx, videoIdx) |
| 154 | - | videoStreamWrap := (*ffmpeg.Stream)(videoStream) | 116 | + | videoStreamWrap := ffmpeg.WrapStream(videoStream) |
| 155 | videoCodecPar := videoStreamWrap.Codecpar() | 117 | videoCodecPar := videoStreamWrap.Codecpar() | ||
| 156 | 118 | ||||
| 157 | // --- Step 6: Open video decoder --- | 119 | // --- Step 6: Open video decoder --- | ||
| @@ -207,7 +169,7 @@ func (p *pipeline) doRun(ctx context.Context) error { | |||||
| 207 | if outVideoStream == nil { | 169 | if outVideoStream == nil { | ||
| 208 | return errors.New("failed to create output video stream") | 170 | return errors.New("failed to create output video stream") | ||
| 209 | } | 171 | } | ||
| 210 | - | outVideoStreamWrap := (*ffmpeg.Stream)(outVideoStream) | 172 | + | outVideoStreamWrap := ffmpeg.WrapStream(outVideoStream) |
| 211 | outVideoIdx := int(outVideoStreamWrap.Index()) | 173 | outVideoIdx := int(outVideoStreamWrap.Index()) | ||
| 212 | 174 | ||||
| 213 | if ret := ffmpeg.CodecParametersFromContext(outVideoStreamWrap.Codecpar(), vidEncCtx); ret < 0 { | 175 | if ret := ffmpeg.CodecParametersFromContext(outVideoStreamWrap.Codecpar(), vidEncCtx); ret < 0 { | ||
| @@ -217,8 +179,8 @@ func (p *pipeline) doRun(ctx context.Context) error { | |||||
| 217 | // --- Step 11b: Add audio output stream (if audio present) --- | 179 | // --- Step 11b: Add audio output stream (if audio present) --- | ||
| 218 | var audioTx *audioTranscoder | 180 | var audioTx *audioTranscoder | ||
| 219 | if audioIdx >= 0 { | 181 | if audioIdx >= 0 { | ||
| 220 | - | audioStream := ffmpeg.FmtCtxStream(inFmtCtx, int(audioIdx)) | 182 | + | audioStream := ffmpeg.FmtCtxStream(inFmtCtx, audioIdx) |
| 221 | - | audioStreamWrap := (*ffmpeg.Stream)(audioStream) | 183 | + | audioStreamWrap := ffmpeg.WrapStream(audioStream) |
| 222 | audioCodecPar := audioStreamWrap.Codecpar() | 184 | audioCodecPar := audioStreamWrap.Codecpar() | ||
| 223 | 185 | ||||
| 224 | var txErr error | 186 | var txErr error | ||
| @@ -271,7 +233,7 @@ func (p *pipeline) doRun(ctx context.Context) error { | |||||
| 271 | break | 233 | break | ||
| 272 | } | 234 | } | ||
| 273 | 235 | ||||
| 274 | - | streamIdx := pktStreamIndex(pkt) | 236 | + | streamIdx := int(pktStreamIndex(pkt)) |
| 275 | 237 | ||||
| 276 | switch { | 238 | switch { | ||
| 277 | case streamIdx == videoIdx: | 239 | case streamIdx == videoIdx: | ||
| @@ -311,7 +273,7 @@ func (p *pipeline) doRun(ctx context.Context) error { | |||||
| 311 | } | 273 | } | ||
| 312 | pktSetStreamIndex(encPkt, int32(outVideoIdx)) | 274 | pktSetStreamIndex(encPkt, int32(outVideoIdx)) | ||
| 313 | outVidStream := ffmpeg.FmtCtxStream(outFmtCtx, outVideoIdx) | 275 | outVidStream := ffmpeg.FmtCtxStream(outFmtCtx, outVideoIdx) | ||
| 314 | - | outVidStreamWrap := (*ffmpeg.Stream)(outVidStream) | 276 | + | outVidStreamWrap := ffmpeg.WrapStream(outVidStream) |
| 315 | ffmpeg.PacketRescaleTs(encPkt, ffmpeg.CodecCtxTimeBase(vidEncCtx), outVidStreamWrap.TimeBase()) | 277 | ffmpeg.PacketRescaleTs(encPkt, ffmpeg.CodecCtxTimeBase(vidEncCtx), outVidStreamWrap.TimeBase()) | ||
| 316 | ffmpeg.InterleavedWriteFrame(outFmtCtx, encPkt) | 278 | ffmpeg.InterleavedWriteFrame(outFmtCtx, encPkt) | ||
| 317 | ffmpeg.PacketUnref(encPkt) | 279 | ffmpeg.PacketUnref(encPkt) | ||
| @@ -330,6 +292,235 @@ func (p *pipeline) doRun(ctx context.Context) error { | |||||
| 330 | return nil | 292 | return nil | ||
| 331 | } | 293 | } | ||
| 332 | 294 | ||||
| 295 | + | func (p *pipeline) selectInputStreams(inFmtCtx unsafe.Pointer) (videoIdx int, audioIdx int, err error) { | |||
| 296 | + | videoIdx = -1 | |||
| 297 | + | audioIdx = -1 | |||
| 298 | + | bestVideoPixels := int64(-1) | |||
| 299 | + | ||||
| 300 | + | streamCount := int(ffmpeg.FmtCtxNbStreams(inFmtCtx)) | |||
| 301 | + | for i := 0; i < streamCount; i++ { | |||
| 302 | + | stream := ffmpeg.FmtCtxStream(inFmtCtx, i) | |||
| 303 | + | if stream == nil { | |||
| 304 | + | continue | |||
| 305 | + | } | |||
| 306 | + | streamWrap := ffmpeg.WrapStream(stream) | |||
| 307 | + | codecPar := streamWrap.Codecpar() | |||
| 308 | + | if codecPar == nil { | |||
| 309 | + | continue | |||
| 310 | + | } | |||
| 311 | + | ||||
| 312 | + | switch ffmpeg.CodecParCodecType(codecPar) { | |||
| 313 | + | case int32(ffmpeg.AVMEDIA_TYPE_VIDEO): | |||
| 314 | + | width := int64(ffmpeg.CodecParWidth(codecPar)) | |||
| 315 | + | height := int64(ffmpeg.CodecParHeight(codecPar)) | |||
| 316 | + | pixels := width * height | |||
| 317 | + | if videoIdx == -1 || pixels > bestVideoPixels { | |||
| 318 | + | videoIdx = i | |||
| 319 | + | bestVideoPixels = pixels | |||
| 320 | + | } | |||
| 321 | + | case int32(ffmpeg.AVMEDIA_TYPE_AUDIO): | |||
| 322 | + | if audioIdx == -1 { | |||
| 323 | + | audioIdx = i | |||
| 324 | + | } | |||
| 325 | + | } | |||
| 326 | + | } | |||
| 327 | + | ||||
| 328 | + | if videoIdx < 0 { | |||
| 329 | + | return -1, audioIdx, errors.New("no video stream found in source") | |||
| 330 | + | } | |||
| 331 | + | ||||
| 332 | + | videoCodecPar := ffmpeg.WrapStream(ffmpeg.FmtCtxStream(inFmtCtx, videoIdx)).Codecpar() | |||
| 333 | + | log := p.logger.With(). | |||
| 334 | + | Str("session_id", p.sessionID). | |||
| 335 | + | Str("source_url", p.sourceURL). | |||
| 336 | + | Int("video_stream_index", videoIdx). | |||
| 337 | + | Int32("video_codec_id", ffmpeg.CodecParCodecID(videoCodecPar)). | |||
| 338 | + | Int32("video_width", ffmpeg.CodecParWidth(videoCodecPar)). | |||
| 339 | + | Int32("video_height", ffmpeg.CodecParHeight(videoCodecPar)). | |||
| 340 | + | Logger() | |||
| 341 | + | if audioIdx >= 0 { | |||
| 342 | + | audioCodecPar := ffmpeg.WrapStream(ffmpeg.FmtCtxStream(inFmtCtx, audioIdx)).Codecpar() | |||
| 343 | + | log.Info(). | |||
| 344 | + | Int("audio_stream_index", audioIdx). | |||
| 345 | + | Int32("audio_codec_id", ffmpeg.CodecParCodecID(audioCodecPar)). | |||
| 346 | + | Msg("transcode pipeline selected input streams") | |||
| 347 | + | } else { | |||
| 348 | + | log.Info().Msg("transcode pipeline selected input streams") | |||
| 349 | + | } | |||
| 350 | + | ||||
| 351 | + | return videoIdx, audioIdx, nil | |||
| 352 | + | } | |||
| 353 | + | ||||
| 354 | + | func (p *pipeline) openInputFormatContext(ctx context.Context) (unsafe.Pointer, func(), error) { | |||
| 355 | + | if IsStreamingManifestURL(p.sourceURL) { | |||
| 356 | + | return p.openManifestInputContext() | |||
| 357 | + | } | |||
| 358 | + | return p.openCustomIOInputContext(ctx) | |||
| 359 | + | } | |||
| 360 | + | ||||
| 361 | + | func (p *pipeline) openCustomIOInputContext(ctx context.Context) (unsafe.Pointer, func(), error) { | |||
| 362 | + | req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.sourceURL, nil) | |||
| 363 | + | if err != nil { | |||
| 364 | + | return nil, nil, fmt.Errorf("create request: %w", err) | |||
| 365 | + | } | |||
| 366 | + | for k, v := range p.headers { | |||
| 367 | + | req.Header.Set(k, v) | |||
| 368 | + | } | |||
| 369 | + | ||||
| 370 | + | resp, err := httpClient.Do(req) | |||
| 371 | + | if err != nil { | |||
| 372 | + | return nil, nil, fmt.Errorf("fetch source: %w", err) | |||
| 373 | + | } | |||
| 374 | + | ||||
| 375 | + | if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent { | |||
| 376 | + | resp.Body.Close() | |||
| 377 | + | return nil, nil, fmt.Errorf("source returned HTTP %d", resp.StatusCode) | |||
| 378 | + | } | |||
| 379 | + | ||||
| 380 | + | body := resp.Body | |||
| 381 | + | readFn := func(_ unsafe.Pointer, buf unsafe.Pointer, bufSize int32) int32 { | |||
| 382 | + | if bufSize <= 0 { | |||
| 383 | + | return -1 | |||
| 384 | + | } | |||
| 385 | + | goBuf := unsafe.Slice((*byte)(buf), int(bufSize)) | |||
| 386 | + | n, readErr := body.Read(goBuf) | |||
| 387 | + | if n > 0 { | |||
| 388 | + | return int32(n) | |||
| 389 | + | } | |||
| 390 | + | if readErr != nil { | |||
| 391 | + | // Return AVERROR_EOF for EOF, generic error otherwise. | |||
| 392 | + | return averrorEOF | |||
| 393 | + | } | |||
| 394 | + | return -1 | |||
| 395 | + | } | |||
| 396 | + | p.readCb = purego.NewCallback(readFn) | |||
| 397 | + | ||||
| 398 | + | readAVIO := ffmpeg.AvioAllocContext(avioBufSize, false, nil, p.readCb, 0, 0) | |||
| 399 | + | if readAVIO == nil { | |||
| 400 | + | resp.Body.Close() | |||
| 401 | + | return nil, nil, errors.New("failed to allocate read AVIO context") | |||
| 402 | + | } | |||
| 403 | + | ||||
| 404 | + | inFmtCtx := ffmpeg.FormatAllocContext() | |||
| 405 | + | if inFmtCtx == nil { | |||
| 406 | + | ffmpeg.AvioContextFree(unsafe.Pointer(&readAVIO)) | |||
| 407 | + | resp.Body.Close() | |||
| 408 | + | return nil, nil, errors.New("failed to allocate input format context") | |||
| 409 | + | } | |||
| 410 | + | ||||
| 411 | + | ffmpeg.FmtCtxSetPB(inFmtCtx, readAVIO) | |||
| 412 | + | ffmpeg.FmtCtxSetFlags(inFmtCtx, ffmpeg.FmtCtxFlags(inFmtCtx)|ffmpeg.AVFMT_FLAG_CUSTOM_IO) | |||
| 413 | + | ||||
| 414 | + | if ret := ffmpeg.FormatOpenInput(unsafe.Pointer(&inFmtCtx), p.sourceURL, nil, nil); ret < 0 { | |||
| 415 | + | ffmpeg.AvioContextFree(unsafe.Pointer(&readAVIO)) | |||
| 416 | + | resp.Body.Close() | |||
| 417 | + | ffmpeg.FormatFreeContext(inFmtCtx) | |||
| 418 | + | return nil, nil, fmt.Errorf("open input: %d", ret) | |||
| 419 | + | } | |||
| 420 | + | ||||
| 421 | + | cleanup := func() { | |||
| 422 | + | ffmpeg.FormatCloseInput(unsafe.Pointer(&inFmtCtx)) | |||
| 423 | + | ffmpeg.AvioContextFree(unsafe.Pointer(&readAVIO)) | |||
| 424 | + | resp.Body.Close() | |||
| 425 | + | } | |||
| 426 | + | return inFmtCtx, cleanup, nil | |||
| 427 | + | } | |||
| 428 | + | ||||
| 429 | + | func (p *pipeline) openManifestInputContext() (unsafe.Pointer, func(), error) { | |||
| 430 | + | inFmtCtx := ffmpeg.FormatAllocContext() | |||
| 431 | + | if inFmtCtx == nil { | |||
| 432 | + | return nil, nil, errors.New("failed to allocate input format context") | |||
| 433 | + | } | |||
| 434 | + | ||||
| 435 | + | var opts unsafe.Pointer | |||
| 436 | + | defer freeInputOptions(&opts) | |||
| 437 | + | ||||
| 438 | + | if err := setInputOption(&opts, "protocol_whitelist", "file,http,https,tcp,tls,crypto"); err != nil { | |||
| 439 | + | ffmpeg.FormatFreeContext(inFmtCtx) | |||
| 440 | + | return nil, nil, err | |||
| 441 | + | } | |||
| 442 | + | if userAgent := p.headers["User-Agent"]; userAgent != "" { | |||
| 443 | + | if err := setInputOption(&opts, "user_agent", userAgent); err != nil { | |||
| 444 | + | ffmpeg.FormatFreeContext(inFmtCtx) | |||
| 445 | + | return nil, nil, err | |||
| 446 | + | } | |||
| 447 | + | } | |||
| 448 | + | if referer := p.headers["Referer"]; referer != "" { | |||
| 449 | + | if err := setInputOption(&opts, "referer", referer); err != nil { | |||
| 450 | + | ffmpeg.FormatFreeContext(inFmtCtx) | |||
| 451 | + | return nil, nil, err | |||
| 452 | + | } | |||
| 453 | + | } | |||
| 454 | + | ||||
| 455 | + | headerLines := formatHTTPHeaderOptions(p.headers) | |||
| 456 | + | if headerLines != "" { | |||
| 457 | + | if err := setInputOption(&opts, "headers", headerLines); err != nil { | |||
| 458 | + | ffmpeg.FormatFreeContext(inFmtCtx) | |||
| 459 | + | return nil, nil, err | |||
| 460 | + | } | |||
| 461 | + | } | |||
| 462 | + | ||||
| 463 | + | if ret := ffmpeg.FormatOpenInput(unsafe.Pointer(&inFmtCtx), p.sourceURL, nil, unsafe.Pointer(&opts)); ret < 0 { | |||
| 464 | + | ffmpeg.FormatFreeContext(inFmtCtx) | |||
| 465 | + | return nil, nil, fmt.Errorf("open manifest input: %d", ret) | |||
| 466 | + | } | |||
| 467 | + | ||||
| 468 | + | return inFmtCtx, func() { | |||
| 469 | + | ffmpeg.FormatCloseInput(unsafe.Pointer(&inFmtCtx)) | |||
| 470 | + | }, nil | |||
| 471 | + | } | |||
| 472 | + | ||||
| 473 | + | func freeInputOptions(opts *unsafe.Pointer) { | |||
| 474 | + | if opts == nil || *opts == nil { | |||
| 475 | + | return | |||
| 476 | + | } | |||
| 477 | + | // av_dict_free expects an AVDictionary**. Passing the dictionary pointer | |||
| 478 | + | // itself corrupts memory and can crash inside libavutil during cleanup. | |||
| 479 | + | ffmpeg.DictFree(unsafe.Pointer(opts)) | |||
| 480 | + | *opts = nil | |||
| 481 | + | } | |||
| 482 | + | ||||
| 483 | + | func setInputOption(opts *unsafe.Pointer, key, value string) error { | |||
| 484 | + | if value == "" { | |||
| 485 | + | return nil | |||
| 486 | + | } | |||
| 487 | + | if ret := ffmpeg.DictSet(unsafe.Pointer(opts), key, value, 0); ret < 0 { | |||
| 488 | + | return fmt.Errorf("set input option %q: %d", key, ret) | |||
| 489 | + | } | |||
| 490 | + | return nil | |||
| 491 | + | } | |||
| 492 | + | ||||
| 493 | + | func formatHTTPHeaderOptions(headers map[string]string) string { | |||
| 494 | + | if len(headers) == 0 { | |||
| 495 | + | return "" | |||
| 496 | + | } | |||
| 497 | + | ||||
| 498 | + | keys := make([]string, 0, len(headers)) | |||
| 499 | + | for key, value := range headers { | |||
| 500 | + | if strings.TrimSpace(value) == "" { | |||
| 501 | + | continue | |||
| 502 | + | } | |||
| 503 | + | if strings.EqualFold(key, "User-Agent") || strings.EqualFold(key, "Referer") { | |||
| 504 | + | continue | |||
| 505 | + | } | |||
| 506 | + | keys = append(keys, key) | |||
| 507 | + | } | |||
| 508 | + | if len(keys) == 0 { | |||
| 509 | + | return "" | |||
| 510 | + | } | |||
| 511 | + | ||||
| 512 | + | sort.Strings(keys) | |||
| 513 | + | ||||
| 514 | + | var builder strings.Builder | |||
| 515 | + | for _, key := range keys { | |||
| 516 | + | builder.WriteString(key) | |||
| 517 | + | builder.WriteString(": ") | |||
| 518 | + | builder.WriteString(headers[key]) | |||
| 519 | + | builder.WriteString("\r\n") | |||
| 520 | + | } | |||
| 521 | + | return builder.String() | |||
| 522 | + | } | |||
| 523 | + | ||||
| 333 | // openVideoDecoder creates and opens a decoder context for the video stream. | 524 | // openVideoDecoder creates and opens a decoder context for the video stream. | ||
| 334 | // It prefers hardware decoders (h264_vaapi, h264_cuvid) based on hwCaps, | 525 | // It prefers hardware decoders (h264_vaapi, h264_cuvid) based on hwCaps, | ||
| 335 | // falling back to the generic software decoder. | 526 | // falling back to the generic software decoder. | ||
| @@ -394,28 +585,50 @@ func (p *pipeline) openVideoEncoder(decCtx unsafe.Pointer) (hwDeviceCtx unsafe.P | |||||
| 394 | return nil, nil, fmt.Errorf("create HW device: %w", hwErr) | 585 | return nil, nil, fmt.Errorf("create HW device: %w", hwErr) | ||
| 395 | } | 586 | } | ||
| 396 | 587 | ||||
| 397 | - | // Find the first working encoder. | 588 | + | width := ffmpeg.CodecCtxWidth(decCtx) |
| 398 | - | var enc unsafe.Pointer | 589 | + | height := ffmpeg.CodecCtxHeight(decCtx) |
| 399 | - | var encoderName string | 590 | + | |
| 591 | + | var lastErr error | |||
| 400 | for _, name := range p.hwCaps.Encoders { | 592 | for _, name := range p.hwCaps.Encoders { | ||
| 401 | - | enc = ffmpeg.CodecFindEncoderByName(name) | 593 | + | enc := ffmpeg.CodecFindEncoderByName(name) |
| 402 | - | if enc != nil { | 594 | + | if enc == nil { |
| 403 | - | encoderName = name | 595 | + | continue |
| 404 | - | break | 596 | + | } |
| 597 | + | ||||
| 598 | + | encCtx, err = p.tryOpenVideoEncoder(decCtx, hwDeviceCtx, name, enc) | |||
| 599 | + | if err == nil { | |||
| 600 | + | p.logger.Info(). | |||
| 601 | + | Str("session_id", p.sessionID). | |||
| 602 | + | Str("source_url", p.sourceURL). | |||
| 603 | + | Str("encoder", name). | |||
| 604 | + | Str("api", p.hwCaps.API). | |||
| 605 | + | Int32("width", width). | |||
| 606 | + | Int32("height", height). | |||
| 607 | + | Msg("transcode pipeline selected video encoder") | |||
| 608 | + | return hwDeviceCtx, encCtx, nil | |||
| 405 | } | 609 | } | ||
| 610 | + | lastErr = err | |||
| 611 | + | p.logger.Warn(). | |||
| 612 | + | Str("session_id", p.sessionID). | |||
| 613 | + | Str("source_url", p.sourceURL). | |||
| 614 | + | Str("encoder", name). | |||
| 615 | + | Err(err). | |||
| 616 | + | Msg("transcode pipeline encoder candidate failed") | |||
| 406 | } | 617 | } | ||
| 407 | - | if enc == nil { | 618 | + | |
| 408 | - | ffmpeg.BufferUnref(&hwDeviceCtx) | 619 | + | ffmpeg.BufferUnref(&hwDeviceCtx) |
| 409 | - | return nil, nil, errors.New("no usable encoder codec found") | 620 | + | if lastErr != nil { |
| 621 | + | return nil, nil, fmt.Errorf("no usable encoder codec found: %w", lastErr) | |||
| 410 | } | 622 | } | ||
| 623 | + | return nil, nil, errors.New("no usable encoder codec found") | |||
| 624 | + | } | |||
| 411 | 625 | ||||
| 412 | - | encCtx = ffmpeg.CodecAllocContext3(enc) | 626 | + | func (p *pipeline) tryOpenVideoEncoder(decCtx, hwDeviceCtx unsafe.Pointer, encoderName string, enc unsafe.Pointer) (unsafe.Pointer, error) { |
| 627 | + | encCtx := ffmpeg.CodecAllocContext3(enc) | |||
| 413 | if encCtx == nil { | 628 | if encCtx == nil { | ||
| 414 | - | ffmpeg.BufferUnref(&hwDeviceCtx) | 629 | + | return nil, errors.New("failed to allocate video encoder context") |
| 415 | - | return nil, nil, errors.New("failed to allocate video encoder context") | |||
| 416 | } | 630 | } | ||
| 417 | 631 | ||||
| 418 | - | // Copy dimensions and framerate from decoder. | |||
| 419 | width := ffmpeg.CodecCtxWidth(decCtx) | 632 | width := ffmpeg.CodecCtxWidth(decCtx) | ||
| 420 | height := ffmpeg.CodecCtxHeight(decCtx) | 633 | height := ffmpeg.CodecCtxHeight(decCtx) | ||
| 421 | ffmpeg.CodecCtxSetWidth(encCtx, width) | 634 | ffmpeg.CodecCtxSetWidth(encCtx, width) | ||
| @@ -428,77 +641,50 @@ func (p *pipeline) openVideoEncoder(decCtx unsafe.Pointer) (hwDeviceCtx unsafe.P | |||||
| 428 | ffmpeg.CodecCtxSetFramerate(encCtx, framerate) | 641 | ffmpeg.CodecCtxSetFramerate(encCtx, framerate) | ||
| 429 | ffmpeg.CodecCtxSetTimeBase(encCtx, ffmpeg.AVRational{Num: framerate.Den, Den: framerate.Num}) | 642 | ffmpeg.CodecCtxSetTimeBase(encCtx, ffmpeg.AVRational{Num: framerate.Den, Den: framerate.Num}) | ||
| 430 | 643 | ||||
| 431 | - | // Set pixel format based on the API. | |||
| 432 | if p.hwCaps.API == "vaapi" { | 644 | if p.hwCaps.API == "vaapi" { | ||
| 433 | ffmpeg.CodecCtxSetPixFmt(encCtx, int32(ffmpeg.PixelFormatPixFmtVaapi)) | 645 | ffmpeg.CodecCtxSetPixFmt(encCtx, int32(ffmpeg.PixelFormatPixFmtVaapi)) | ||
| 434 | } else { | 646 | } else { | ||
| 435 | - | // CUDA/NVENC uses NV12. | |||
| 436 | ffmpeg.CodecCtxSetPixFmt(encCtx, int32(ffmpeg.PixelFormatPixFmtNv12)) | 647 | ffmpeg.CodecCtxSetPixFmt(encCtx, int32(ffmpeg.PixelFormatPixFmtNv12)) | ||
| 437 | } | 648 | } | ||
| 438 | 649 | ||||
| 439 | - | // Set GOP size to 2 seconds worth of frames. | |||
| 440 | gopSize := int32(framerate.Num / framerate.Den * 2) | 650 | gopSize := int32(framerate.Num / framerate.Den * 2) | ||
| 441 | if gopSize <= 0 { | 651 | if gopSize <= 0 { | ||
| 442 | gopSize = 60 | 652 | gopSize = 60 | ||
| 443 | } | 653 | } | ||
| 444 | codecCtxSetGopSize(encCtx, gopSize) | 654 | codecCtxSetGopSize(encCtx, gopSize) | ||
| 445 | - | ||||
| 446 | - | // Set quality/bitrate based on quality parameter. | |||
| 447 | p.applyQualityPreset(encCtx, encoderName, width, height) | 655 | p.applyQualityPreset(encCtx, encoderName, width, height) | ||
| 448 | 656 | ||||
| 449 | - | // Attach HW device context. Use BufferRef to create a new reference | |||
| 450 | - | // since the encoder will take ownership of one ref. | |||
| 451 | hwDeviceRef := ffmpeg.BufferRef(hwDeviceCtx) | 657 | hwDeviceRef := ffmpeg.BufferRef(hwDeviceCtx) | ||
| 452 | if hwDeviceRef == nil { | 658 | if hwDeviceRef == nil { | ||
| 453 | ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx)) | 659 | ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx)) | ||
| 454 | - | ffmpeg.BufferUnref(&hwDeviceCtx) | 660 | + | return nil, errors.New("failed to ref HW device context for encoder") |
| 455 | - | return nil, nil, errors.New("failed to ref HW device context for encoder") | |||
| 456 | } | 661 | } | ||
| 457 | ffmpeg.CodecCtxSetHwDeviceCtx(encCtx, hwDeviceRef) | 662 | ffmpeg.CodecCtxSetHwDeviceCtx(encCtx, hwDeviceRef) | ||
| 458 | 663 | ||||
| 459 | - | // For VAAPI encoders, we need a HW frames context. | |||
| 460 | if p.hwCaps.API == "vaapi" { | 664 | if p.hwCaps.API == "vaapi" { | ||
| 461 | framesRef := ffmpeg.HWFrameCtxAlloc(hwDeviceCtx) | 665 | framesRef := ffmpeg.HWFrameCtxAlloc(hwDeviceCtx) | ||
| 462 | if framesRef == nil { | 666 | if framesRef == nil { | ||
| 463 | ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx)) | 667 | ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx)) | ||
| 464 | - | ffmpeg.BufferUnref(&hwDeviceCtx) | 668 | + | return nil, errors.New("failed to allocate HW frames context") |
| 465 | - | return nil, nil, errors.New("failed to allocate HW frames context") | |||
| 466 | - | } | |||
| 467 | - | ||||
| 468 | - | // Configure the frames context. The HWFramesContext struct starts | |||
| 469 | - | // with an AVBufferRef header. The actual AVHWFramesContext data is | |||
| 470 | - | // accessible after dereferencing. We configure via av_opt or direct | |||
| 471 | - | // offset access. For simplicity, use the known offsets: | |||
| 472 | - | // AVHWFramesContext (within the AVBufferRef->data): | |||
| 473 | - | // format (sw_format) at offset 8, width at 12, height at 16, | |||
| 474 | - | // sw_format at 20, initial_pool_size at 24. | |||
| 475 | - | // Actually, the buffer ref data pointer leads to AVHWFramesContext. | |||
| 476 | - | // The fields are: AVClass* (8), AVBufferRef* device_ref (8), | |||
| 477 | - | // AVBufferPool* pool (8), int initial_pool_size (4), | |||
| 478 | - | // enum AVPixelFormat format (4), enum AVPixelFormat sw_format (4), | |||
| 479 | - | // int width (4), int height (4). | |||
| 480 | - | // So from the data pointer: | |||
| 481 | - | // offset 24: initial_pool_size (int32) | |||
| 482 | - | // offset 28: format (int32) — the HW pixel format | |||
| 483 | - | // offset 32: sw_format (int32) — the SW pixel format | |||
| 484 | - | // offset 36: width (int32) | |||
| 485 | - | // offset 40: height (int32) | |||
| 486 | - | // | |||
| 487 | - | // We access data via the AVBufferRef at offset 0 (data field at offset 0). | |||
| 488 | - | hwFramesData := *(*unsafe.Pointer)(framesRef) // AVBufferRef->data | |||
| 489 | - | if hwFramesData != nil { | |||
| 490 | - | *(*int32)(unsafe.Add(hwFramesData, 24)) = 20 // initial_pool_size | |||
| 491 | - | *(*int32)(unsafe.Add(hwFramesData, 28)) = int32(ffmpeg.PixelFormatPixFmtVaapi) | |||
| 492 | - | *(*int32)(unsafe.Add(hwFramesData, 32)) = int32(ffmpeg.PixelFormatPixFmtNv12) // sw_format | |||
| 493 | - | *(*int32)(unsafe.Add(hwFramesData, 36)) = width | |||
| 494 | - | *(*int32)(unsafe.Add(hwFramesData, 40)) = height | |||
| 495 | } | 669 | } | ||
| 496 | 670 | ||||
| 671 | + | hwFramesData := ffmpeg.BufferRefData(framesRef) | |||
| 672 | + | if hwFramesData == nil { | |||
| 673 | + | ffmpeg.BufferUnref(&framesRef) | |||
| 674 | + | ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx)) | |||
| 675 | + | return nil, errors.New("failed to access HW frames context payload") | |||
| 676 | + | } | |||
| 677 | + | ||||
| 678 | + | ffmpeg.HWFramesCtxSetInitialPoolSize(hwFramesData, 20) | |||
| 679 | + | ffmpeg.HWFramesCtxSetFormat(hwFramesData, int32(ffmpeg.PixelFormatPixFmtVaapi)) | |||
| 680 | + | ffmpeg.HWFramesCtxSetSWFormat(hwFramesData, int32(ffmpeg.PixelFormatPixFmtNv12)) | |||
| 681 | + | ffmpeg.HWFramesCtxSetWidth(hwFramesData, width) | |||
| 682 | + | ffmpeg.HWFramesCtxSetHeight(hwFramesData, height) | |||
| 683 | + | ||||
| 497 | if ret := ffmpeg.HWFrameCtxInit(framesRef); ret < 0 { | 684 | if ret := ffmpeg.HWFrameCtxInit(framesRef); ret < 0 { | ||
| 498 | ffmpeg.BufferUnref(&framesRef) | 685 | ffmpeg.BufferUnref(&framesRef) | ||
| 499 | ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx)) | 686 | ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx)) | ||
| 500 | - | ffmpeg.BufferUnref(&hwDeviceCtx) | 687 | + | return nil, fmt.Errorf("init HW frames context: %d", ret) |
| 501 | - | return nil, nil, fmt.Errorf("init HW frames context: %d", ret) | |||
| 502 | } | 688 | } | ||
| 503 | 689 | ||||
| 504 | ffmpeg.CodecCtxSetHwFramesCtx(encCtx, framesRef) | 690 | ffmpeg.CodecCtxSetHwFramesCtx(encCtx, framesRef) | ||
| @@ -506,11 +692,10 @@ func (p *pipeline) openVideoEncoder(decCtx unsafe.Pointer) (hwDeviceCtx unsafe.P | |||||
| 506 | 692 | ||||
| 507 | if ret := ffmpeg.CodecOpen2(encCtx, enc, nil); ret < 0 { | 693 | if ret := ffmpeg.CodecOpen2(encCtx, enc, nil); ret < 0 { | ||
| 508 | ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx)) | 694 | ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx)) | ||
| 509 | - | ffmpeg.BufferUnref(&hwDeviceCtx) | 695 | + | return nil, fmt.Errorf("open video encoder %s: %d", encoderName, ret) |
| 510 | - | return nil, nil, fmt.Errorf("open video encoder %s: %d", encoderName, ret) | |||
| 511 | } | 696 | } | ||
| 512 | 697 | ||||
| 513 | - | return hwDeviceCtx, encCtx, nil | 698 | + | return encCtx, nil |
| 514 | } | 699 | } | ||
| 515 | 700 | ||||
| 516 | // applyQualityPreset sets bitrate and encoder options based on the | 701 | // applyQualityPreset sets bitrate and encoder options based on the | ||
| @@ -582,12 +767,18 @@ func (p *pipeline) transcodeVideoPacket(pkt, frame, encPkt, decCtx, encCtx, outF | |||||
| 582 | // encodeAndWriteVideoFrame sends a decoded frame to the encoder and | 767 | // encodeAndWriteVideoFrame sends a decoded frame to the encoder and | ||
| 583 | // writes any resulting packets to the output. | 768 | // writes any resulting packets to the output. | ||
| 584 | func (p *pipeline) encodeAndWriteVideoFrame(frame, encPkt, encCtx, outFmtCtx unsafe.Pointer, inStreamWrap *ffmpeg.Stream, outVideoIdx int) error { | 769 | func (p *pipeline) encodeAndWriteVideoFrame(frame, encPkt, encCtx, outFmtCtx unsafe.Pointer, inStreamWrap *ffmpeg.Stream, outVideoIdx int) error { | ||
| 585 | - | // If the frame is in SW format and the encoder expects HW format, | 770 | + | encFrame := frame |
| 586 | - | // we would need to upload. For now, send directly — the HW encoder | 771 | + | var cleanup func() |
| 587 | - | // with hw_device_ctx set should handle format conversion internally | 772 | + | if p.hwCaps.API == "vaapi" { |
| 588 | - | // when the decoder outputs SW frames. | 773 | + | var err error |
| 774 | + | encFrame, cleanup, err = p.uploadVideoFrameToVAAPI(frame, encCtx) | |||
| 775 | + | if err != nil { | |||
| 776 | + | return err | |||
| 777 | + | } | |||
| 778 | + | defer cleanup() | |||
| 779 | + | } | |||
| 589 | 780 | ||||
| 590 | - | ret := ffmpeg.CodecSendFrame(encCtx, frame) | 781 | + | ret := ffmpeg.CodecSendFrame(encCtx, encFrame) |
| 591 | if ret < 0 { | 782 | if ret < 0 { | ||
| 592 | return fmt.Errorf("video encode send frame: %d", ret) | 783 | return fmt.Errorf("video encode send frame: %d", ret) | ||
| 593 | } | 784 | } | ||
| @@ -601,7 +792,7 @@ func (p *pipeline) encodeAndWriteVideoFrame(frame, encPkt, encCtx, outFmtCtx uns | |||||
| 601 | pktSetStreamIndex(encPkt, int32(outVideoIdx)) | 792 | pktSetStreamIndex(encPkt, int32(outVideoIdx)) | ||
| 602 | 793 | ||||
| 603 | outVidStream := ffmpeg.FmtCtxStream(outFmtCtx, outVideoIdx) | 794 | outVidStream := ffmpeg.FmtCtxStream(outFmtCtx, outVideoIdx) | ||
| 604 | - | outVidStreamWrap := (*ffmpeg.Stream)(outVidStream) | 795 | + | outVidStreamWrap := ffmpeg.WrapStream(outVidStream) |
| 605 | ffmpeg.PacketRescaleTs(encPkt, ffmpeg.CodecCtxTimeBase(encCtx), outVidStreamWrap.TimeBase()) | 796 | ffmpeg.PacketRescaleTs(encPkt, ffmpeg.CodecCtxTimeBase(encCtx), outVidStreamWrap.TimeBase()) | ||
| 606 | 797 | ||||
| 607 | if wret := ffmpeg.InterleavedWriteFrame(outFmtCtx, encPkt); wret < 0 { | 798 | if wret := ffmpeg.InterleavedWriteFrame(outFmtCtx, encPkt); wret < 0 { | ||
| @@ -614,5 +805,50 @@ func (p *pipeline) encodeAndWriteVideoFrame(frame, encPkt, encCtx, outFmtCtx uns | |||||
| 614 | return nil | 805 | return nil | ||
| 615 | } | 806 | } | ||
| 616 | 807 | ||||
| 808 | + | func (p *pipeline) uploadVideoFrameToVAAPI(frame, encCtx unsafe.Pointer) (unsafe.Pointer, func(), error) { | |||
| 809 | + | if frameFormat(frame) == int32(ffmpeg.PixelFormatPixFmtVaapi) { | |||
| 810 | + | return frame, func() {}, nil | |||
| 811 | + | } | |||
| 812 | + | ||||
| 813 | + | hwFramesCtx := ffmpeg.CodecCtxHwFramesCtx(encCtx) | |||
| 814 | + | if hwFramesCtx == nil { | |||
| 815 | + | return nil, nil, errors.New("video encoder missing hw_frames_ctx") | |||
| 816 | + | } | |||
| 817 | + | ||||
| 818 | + | hwFrame := ffmpeg.FrameAlloc() | |||
| 819 | + | if hwFrame == nil { | |||
| 820 | + | return nil, nil, errors.New("failed to allocate VAAPI frame") | |||
| 821 | + | } | |||
| 822 | + | ||||
| 823 | + | cleanup := func() { | |||
| 824 | + | if hwFrame != nil { | |||
| 825 | + | ffmpeg.FrameFree(unsafe.Pointer(&hwFrame)) | |||
| 826 | + | } | |||
| 827 | + | } | |||
| 828 | + | ||||
| 829 | + | frameSetFormat(hwFrame, int32(ffmpeg.PixelFormatPixFmtVaapi)) | |||
| 830 | + | frameSetWidth(hwFrame, frameWidth(frame)) | |||
| 831 | + | frameSetHeight(hwFrame, frameHeight(frame)) | |||
| 832 | + | frameSetPts(hwFrame, framePts(frame)) | |||
| 833 | + | ||||
| 834 | + | hwFramesRef := ffmpeg.BufferRef(hwFramesCtx) | |||
| 835 | + | if hwFramesRef == nil { | |||
| 836 | + | cleanup() | |||
| 837 | + | return nil, nil, errors.New("failed to ref encoder hw frames context") | |||
| 838 | + | } | |||
| 839 | + | frameSetHWFramesCtx(hwFrame, hwFramesRef) | |||
| 840 | + | ||||
| 841 | + | if ret := ffmpeg.HWFrameGetBuffer(hwFramesCtx, hwFrame, 0); ret < 0 { | |||
| 842 | + | cleanup() | |||
| 843 | + | return nil, nil, fmt.Errorf("allocate VAAPI frame buffer: %d", ret) | |||
| 844 | + | } | |||
| 845 | + | if ret := ffmpeg.HWFrameTransferData(hwFrame, frame, 0); ret < 0 { | |||
| 846 | + | cleanup() | |||
| 847 | + | return nil, nil, fmt.Errorf("upload frame to VAAPI: %d", ret) | |||
| 848 | + | } | |||
| 849 | + | ||||
| 850 | + | return hwFrame, cleanup, nil | |||
| 851 | + | } | |||
| 852 | + | ||||
| 617 | // averrorEOF is the AVERROR_EOF constant. | 853 | // averrorEOF is the AVERROR_EOF constant. | ||
| 618 | const averrorEOF = -('E' | 'O'<<8 | 'F'<<16 | ' '<<24) | 854 | const averrorEOF = -('E' | 'O'<<8 | 'F'<<16 | ' '<<24) | ||
M internal/infrastructure/transcoder/transcoder.go +1 -1
| @@ -111,7 +111,7 @@ func (t *Transcoder) Start(ctx context.Context, sourceURL string, headers map[st | |||
| 111 | 111 | return nil, fmt.Errorf("transcoder: %w", err) | |
| 112 | 112 | } | |
| 113 | 113 | ||
| 114 | - | p := newPipeline(t.hwCaps, sourceURL, headers, t.cfg.Quality, pw, t.logger) | |
| 114 | + | p := newPipeline(id, t.hwCaps, sourceURL, headers, t.cfg.Quality, pw, t.logger) | |
| 115 | 115 | ||
| 116 | 116 | t.logger.Info(). | |
| 117 | 117 | Str("session_id", id). | |
| @@ -111,7 +111,7 @@ func (t *Transcoder) Start(ctx context.Context, sourceURL string, headers map[st | |||||
| 111 | return nil, fmt.Errorf("transcoder: %w", err) | 111 | return nil, fmt.Errorf("transcoder: %w", err) | ||
| 112 | } | 112 | } | ||
| 113 | 113 | ||||
| 114 | - | p := newPipeline(t.hwCaps, sourceURL, headers, t.cfg.Quality, pw, t.logger) | 114 | + | p := newPipeline(id, t.hwCaps, sourceURL, headers, t.cfg.Quality, pw, t.logger) |
| 115 | 115 | ||||
| 116 | t.logger.Info(). | 116 | t.logger.Info(). | ||
| 117 | Str("session_id", id). | 117 | Str("session_id", id). | ||
M internal/logging/logger.go +50 -2
| @@ -1,9 +1,11 @@ | |||
| 1 | 1 | package logging | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "errors" | |
| 4 | 5 | "io" | |
| 5 | 6 | "os" | |
| 6 | 7 | "path/filepath" | |
| 8 | + | "sync" | |
| 7 | 9 | "time" | |
| 8 | 10 | ||
| 9 | 11 | "github.com/rs/zerolog" | |
| @@ -105,8 +107,9 @@ func NewWithFile(cfg Config, fileCfg FileConfig) (zerolog.Logger, func(), error) | |||
| 105 | 107 | return zerolog.Logger{}, nil, err | |
| 106 | 108 | } | |
| 107 | 109 | ||
| 108 | - | writers = append(writers, file) | |
| 109 | - | cleanup = func() { _ = file.Close() } | |
| 110 | + | sessionWriter := newSessionFileWriter(file) | |
| 111 | + | writers = append(writers, sessionWriter) | |
| 112 | + | cleanup = func() { _ = sessionWriter.Close() } | |
| 110 | 113 | } | |
| 111 | 114 | ||
| 112 | 115 | // Fallback to discard if no writers configured | |
| @@ -123,6 +126,51 @@ func NewWithFile(cfg Config, fileCfg FileConfig) (zerolog.Logger, func(), error) | |||
| 123 | 126 | return ctx.Logger(), cleanup, nil | |
| 124 | 127 | } | |
| 125 | 128 | ||
| 129 | + | type sessionFileWriter struct { | |
| 130 | + | mu sync.Mutex | |
| 131 | + | file *os.File | |
| 132 | + | closed bool | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | func newSessionFileWriter(file *os.File) *sessionFileWriter { | |
| 136 | + | return &sessionFileWriter{file: file} | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | func (w *sessionFileWriter) Write(p []byte) (int, error) { | |
| 140 | + | w.mu.Lock() | |
| 141 | + | defer w.mu.Unlock() | |
| 142 | + | ||
| 143 | + | if w.closed || w.file == nil { | |
| 144 | + | return len(p), nil | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | n, err := w.file.Write(p) | |
| 148 | + | if errors.Is(err, os.ErrClosed) { | |
| 149 | + | w.closed = true | |
| 150 | + | w.file = nil | |
| 151 | + | return len(p), nil | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | return n, err | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | func (w *sessionFileWriter) Close() error { | |
| 158 | + | w.mu.Lock() | |
| 159 | + | defer w.mu.Unlock() | |
| 160 | + | ||
| 161 | + | if w.closed || w.file == nil { | |
| 162 | + | return nil | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | err := w.file.Close() | |
| 166 | + | w.closed = true | |
| 167 | + | w.file = nil | |
| 168 | + | if errors.Is(err, os.ErrClosed) { | |
| 169 | + | return nil | |
| 170 | + | } | |
| 171 | + | return err | |
| 172 | + | } | |
| 173 | + | ||
| 126 | 174 | // NewFromEnv creates a logger based on environment variables | |
| 127 | 175 | // DUMBER_LOG_LEVEL: trace, debug, info, warn, error (default: info) | |
| 128 | 176 | // DUMBER_LOG_FORMAT: json, console (default: console) | |
| @@ -1,9 +1,11 @@ | |||||
| 1 | package logging | 1 | package logging | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "errors" | |||
| 4 | "io" | 5 | "io" | ||
| 5 | "os" | 6 | "os" | ||
| 6 | "path/filepath" | 7 | "path/filepath" | ||
| 8 | + | "sync" | |||
| 7 | "time" | 9 | "time" | ||
| 8 | 10 | ||||
| 9 | "github.com/rs/zerolog" | 11 | "github.com/rs/zerolog" | ||
| @@ -105,8 +107,9 @@ func NewWithFile(cfg Config, fileCfg FileConfig) (zerolog.Logger, func(), error) | |||||
| 105 | return zerolog.Logger{}, nil, err | 107 | return zerolog.Logger{}, nil, err | ||
| 106 | } | 108 | } | ||
| 107 | 109 | ||||
| 108 | - | writers = append(writers, file) | 110 | + | sessionWriter := newSessionFileWriter(file) |
| 109 | - | cleanup = func() { _ = file.Close() } | 111 | + | writers = append(writers, sessionWriter) |
| 112 | + | cleanup = func() { _ = sessionWriter.Close() } | |||
| 110 | } | 113 | } | ||
| 111 | 114 | ||||
| 112 | // Fallback to discard if no writers configured | 115 | // Fallback to discard if no writers configured | ||
| @@ -123,6 +126,51 @@ func NewWithFile(cfg Config, fileCfg FileConfig) (zerolog.Logger, func(), error) | |||||
| 123 | return ctx.Logger(), cleanup, nil | 126 | return ctx.Logger(), cleanup, nil | ||
| 124 | } | 127 | } | ||
| 125 | 128 | ||||
| 129 | + | type sessionFileWriter struct { | |||
| 130 | + | mu sync.Mutex | |||
| 131 | + | file *os.File | |||
| 132 | + | closed bool | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | func newSessionFileWriter(file *os.File) *sessionFileWriter { | |||
| 136 | + | return &sessionFileWriter{file: file} | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | func (w *sessionFileWriter) Write(p []byte) (int, error) { | |||
| 140 | + | w.mu.Lock() | |||
| 141 | + | defer w.mu.Unlock() | |||
| 142 | + | ||||
| 143 | + | if w.closed || w.file == nil { | |||
| 144 | + | return len(p), nil | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | n, err := w.file.Write(p) | |||
| 148 | + | if errors.Is(err, os.ErrClosed) { | |||
| 149 | + | w.closed = true | |||
| 150 | + | w.file = nil | |||
| 151 | + | return len(p), nil | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | return n, err | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | func (w *sessionFileWriter) Close() error { | |||
| 158 | + | w.mu.Lock() | |||
| 159 | + | defer w.mu.Unlock() | |||
| 160 | + | ||||
| 161 | + | if w.closed || w.file == nil { | |||
| 162 | + | return nil | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | err := w.file.Close() | |||
| 166 | + | w.closed = true | |||
| 167 | + | w.file = nil | |||
| 168 | + | if errors.Is(err, os.ErrClosed) { | |||
| 169 | + | return nil | |||
| 170 | + | } | |||
| 171 | + | return err | |||
| 172 | + | } | |||
| 173 | + | ||||
| 126 | // NewFromEnv creates a logger based on environment variables | 174 | // NewFromEnv creates a logger based on environment variables | ||
| 127 | // DUMBER_LOG_LEVEL: trace, debug, info, warn, error (default: info) | 175 | // DUMBER_LOG_LEVEL: trace, debug, info, warn, error (default: info) | ||
| 128 | // DUMBER_LOG_FORMAT: json, console (default: console) | 176 | // DUMBER_LOG_FORMAT: json, console (default: console) | ||
A internal/logging/logger_test.go +74 -0
| @@ -0,0 +1,74 @@ | |||
| 1 | + | package logging | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "path/filepath" | |
| 6 | + | "testing" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestSessionFileWriter_DropsWritesAfterClose(t *testing.T) { | |
| 10 | + | t.Parallel() | |
| 11 | + | ||
| 12 | + | dir := t.TempDir() | |
| 13 | + | path := filepath.Join(dir, "session.log") | |
| 14 | + | ||
| 15 | + | file, err := os.Create(path) | |
| 16 | + | if err != nil { | |
| 17 | + | t.Fatalf("create temp log file: %v", err) | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | writer := newSessionFileWriter(file) | |
| 21 | + | ||
| 22 | + | n, err := writer.Write([]byte("first\n")) | |
| 23 | + | if err != nil { | |
| 24 | + | t.Fatalf("write before close: %v", err) | |
| 25 | + | } | |
| 26 | + | if n != len("first\n") { | |
| 27 | + | t.Fatalf("write before close returned %d, want %d", n, len("first\n")) | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | if err := writer.Close(); err != nil { | |
| 31 | + | t.Fatalf("close writer: %v", err) | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | n, err = writer.Write([]byte("second\n")) | |
| 35 | + | if err != nil { | |
| 36 | + | t.Fatalf("write after close: %v", err) | |
| 37 | + | } | |
| 38 | + | if n != len("second\n") { | |
| 39 | + | t.Fatalf("write after close returned %d, want %d", n, len("second\n")) | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | data, err := os.ReadFile(path) | |
| 43 | + | if err != nil { | |
| 44 | + | t.Fatalf("read log file: %v", err) | |
| 45 | + | } | |
| 46 | + | if got, want := string(data), "first\n"; got != want { | |
| 47 | + | t.Fatalf("log file contents = %q, want %q", got, want) | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func TestSessionFileWriter_IgnoresExternallyClosedFile(t *testing.T) { | |
| 52 | + | t.Parallel() | |
| 53 | + | ||
| 54 | + | dir := t.TempDir() | |
| 55 | + | path := filepath.Join(dir, "session.log") | |
| 56 | + | ||
| 57 | + | file, err := os.Create(path) | |
| 58 | + | if err != nil { | |
| 59 | + | t.Fatalf("create temp log file: %v", err) | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | writer := newSessionFileWriter(file) | |
| 63 | + | if err := file.Close(); err != nil { | |
| 64 | + | t.Fatalf("close underlying file: %v", err) | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | n, err := writer.Write([]byte("late\n")) | |
| 68 | + | if err != nil { | |
| 69 | + | t.Fatalf("write after external close: %v", err) | |
| 70 | + | } | |
| 71 | + | if n != len("late\n") { | |
| 72 | + | t.Fatalf("write after external close returned %d, want %d", n, len("late\n")) | |
| 73 | + | } | |
| 74 | + | } | |
| @@ -0,0 +1,74 @@ | |||||
| 1 | + | package logging | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "os" | |||
| 5 | + | "path/filepath" | |||
| 6 | + | "testing" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestSessionFileWriter_DropsWritesAfterClose(t *testing.T) { | |||
| 10 | + | t.Parallel() | |||
| 11 | + | ||||
| 12 | + | dir := t.TempDir() | |||
| 13 | + | path := filepath.Join(dir, "session.log") | |||
| 14 | + | ||||
| 15 | + | file, err := os.Create(path) | |||
| 16 | + | if err != nil { | |||
| 17 | + | t.Fatalf("create temp log file: %v", err) | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | writer := newSessionFileWriter(file) | |||
| 21 | + | ||||
| 22 | + | n, err := writer.Write([]byte("first\n")) | |||
| 23 | + | if err != nil { | |||
| 24 | + | t.Fatalf("write before close: %v", err) | |||
| 25 | + | } | |||
| 26 | + | if n != len("first\n") { | |||
| 27 | + | t.Fatalf("write before close returned %d, want %d", n, len("first\n")) | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | if err := writer.Close(); err != nil { | |||
| 31 | + | t.Fatalf("close writer: %v", err) | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | n, err = writer.Write([]byte("second\n")) | |||
| 35 | + | if err != nil { | |||
| 36 | + | t.Fatalf("write after close: %v", err) | |||
| 37 | + | } | |||
| 38 | + | if n != len("second\n") { | |||
| 39 | + | t.Fatalf("write after close returned %d, want %d", n, len("second\n")) | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | data, err := os.ReadFile(path) | |||
| 43 | + | if err != nil { | |||
| 44 | + | t.Fatalf("read log file: %v", err) | |||
| 45 | + | } | |||
| 46 | + | if got, want := string(data), "first\n"; got != want { | |||
| 47 | + | t.Fatalf("log file contents = %q, want %q", got, want) | |||
| 48 | + | } | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | func TestSessionFileWriter_IgnoresExternallyClosedFile(t *testing.T) { | |||
| 52 | + | t.Parallel() | |||
| 53 | + | ||||
| 54 | + | dir := t.TempDir() | |||
| 55 | + | path := filepath.Join(dir, "session.log") | |||
| 56 | + | ||||
| 57 | + | file, err := os.Create(path) | |||
| 58 | + | if err != nil { | |||
| 59 | + | t.Fatalf("create temp log file: %v", err) | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | writer := newSessionFileWriter(file) | |||
| 63 | + | if err := file.Close(); err != nil { | |||
| 64 | + | t.Fatalf("close underlying file: %v", err) | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | n, err := writer.Write([]byte("late\n")) | |||
| 68 | + | if err != nil { | |||
| 69 | + | t.Fatalf("write after external close: %v", err) | |||
| 70 | + | } | |||
| 71 | + | if n != len("late\n") { | |||
| 72 | + | t.Fatalf("write after external close returned %d, want %d", n, len("late\n")) | |||
| 73 | + | } | |||
| 74 | + | } | |||