detached aeff368

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
23 files · +1389 -217
M cmd/dumber/main.go +1 -1
@@ -200,10 +200,10 @@ func initBrowserSession(
200200 bootstrapLog.Fatal().Err(err).Msg("failed to start session")
201201 }
202202 cleanup := func() {
203+ _ = browserSession.End(sessionCtx)
203204 if browserSession.LogCleanup != nil {
204205 browserSession.LogCleanup()
205206 }
206- _ = browserSession.End(sessionCtx)
207207 }
208208 return sessionCtx, browserSession, cleanup
209209 }
@@ -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, cleanup208 return sessionCtx, browserSession, cleanup
209 }209 }
M internal/bootstrap/engine.go +1 -1
@@ -42,7 +42,7 @@ func BuildEngine(input EngineInput) (port.Engine, error) {
4242 )
4343 case "cef":
4444 cefCfg := cfg.Engine.CEF
45- return cef.NewEngine(input.Ctx, cefCfg)
45+ return cef.NewEngine(input.Ctx, cefCfg, cfg.Transcoding)
4646 default:
4747 return nil, fmt.Errorf("unknown engine type: %q", engineType)
4848 }
@@ -42,7 +42,7 @@ func BuildEngine(input EngineInput) (port.Engine, error) {
42 )42 )
43 case "cef":43 case "cef":
44 cefCfg := cfg.Engine.CEF44 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(){
156156 'aac:', MediaSource ? MediaSource.isTypeSupported('audio/mp4; codecs="mp4a.40.2"') : 'n/a');
157157 })();`
158158
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+
159369 // contentInjector implements port.ContentInjector for the CEF engine.
160370 // It stores CSS strings and injects them into webviews via ExecuteJavaScript.
161371 // Thread-safe: InjectThemeCSS may be called from the UI thread while OnLoadEnd
@@ -275,6 +485,10 @@ func (ci *contentInjector) onLoadEnd(wv *WebView) {
275485 // Video playback diagnostic — logs video element state changes.
276486 // TODO: remove once video playback is stable.
277487 wv.RunJavaScript(context.Background(), videoDiagnosticJS)
488+
489+ if strings.Contains(uri, "reddit.com") {
490+ wv.RunJavaScript(context.Background(), redditDirectVideoJS)
491+ }
278492 }
279493
280494 // 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 OnLoadEnd371 // 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 {
5858 browserCreateLastWidth atomic.Int32
5959 browserCreateLastHeight atomic.Int32
6060 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
6176 }
6277
6378 // Factory returns the WebViewFactory for creating new WebView instances.
@@ -299,6 +314,7 @@ func (e *Engine) SetHandlerContext(ctx context.Context) {
299314 if e.messageRouter != nil {
300315 e.messageRouter.SetBaseContext(ctx)
301316 }
317+ e.logTranscoderStartupState()
302318 }
303319
304320 const browserCreateStallWarnMS int64 = 1500
@@ -400,6 +416,43 @@ func (e *Engine) maybeLogBrowserCreateStall() {
400416 Msg("cef: browser creation appears stalled")
401417 }
402418
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+
403456 // ---------------------------------------------------------------------------
404457 // CEF message pump
405458 // ---------------------------------------------------------------------------
@@ -58,6 +58,21 @@ type Engine struct {
58 browserCreateLastWidth atomic.Int3258 browserCreateLastWidth atomic.Int32
59 browserCreateLastHeight atomic.Int3259 browserCreateLastHeight atomic.Int32
60 lastStallLoggedCreateSeq atomic.Uint6460 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 = 1500320 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 pump457 // CEF message pump
405 // ---------------------------------------------------------------------------458 // ---------------------------------------------------------------------------
M internal/infrastructure/cef/engine_init.go +46 -7
@@ -23,7 +23,7 @@ import (
2323 const puregoCEFInitTraceEnvVar = "PUREGO_CEF_INIT_TRACE"
2424
2525 // 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) {
2727 logger := logging.FromContext(ctx)
2828 cleanStaleSingletonLocks(logger)
2929
@@ -60,7 +60,7 @@ func NewEngine(ctx context.Context, cfg config.CEFEngineConfig) (*Engine, error)
6060 }
6161 os.Args = savedArgs
6262
63- return wireEngine(ctx, eng, cfg, windowlessFrameRate, logger)
63+ return wireEngine(ctx, eng, cfg, transcodingCfg, windowlessFrameRate, logger)
6464 }
6565
6666 // prepareCEFSettings builds purecef.Settings from the engine config.
@@ -118,7 +118,7 @@ func initializeCEF(eng *Engine, settings purecef.Settings, multiThreaded bool, l
118118
119119 // wireEngine creates GL loader, factory, pool, and scheme handler after CEF init.
120120 func wireEngine(
121- ctx context.Context, eng *Engine, cfg config.CEFEngineConfig,
121+ ctx context.Context, eng *Engine, cfg config.CEFEngineConfig, transcodingCfg config.TranscodingConfig,
122122 windowlessFrameRate int32, logger *zerolog.Logger,
123123 ) (*Engine, error) {
124124 gl, err := newGLLoader()
@@ -140,17 +140,25 @@ func wireEngine(
140140 }
141141 }
142142
143- // Initialize GPU transcoder if enabled in the global config.
143+ // Initialize the GPU transcoder when enabled in the loaded app config.
144144 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...)
147153 if tc.Available() {
148154 mediaTranscoder = tc
155+ transcoderState.Status = "available"
149156 logger.Info().
150157 Str("api", tc.Capabilities().API).
151158 Strs("encoders", tc.Capabilities().Encoders).
152159 Msg("cef: GPU transcoding available")
153160 } else {
161+ transcoderState.Status = "unavailable_no_compatible_gpu"
154162 logger.Warn().Msg("cef: GPU transcoding enabled but no compatible GPU found — feature disabled")
155163 }
156164 }
@@ -167,9 +175,10 @@ func wireEngine(
167175 eng.factory = factory
168176 eng.pool = pool
169177 eng.contentInj = newContentInjector(eng, nil)
178+ eng.transcoderState = transcoderState
170179
171180 messageRouter := NewMessageRouter(ctx)
172- schemeHandler := newDumbSchemeHandler(ctx, messageRouter)
181+ schemeHandler := newDumbSchemeHandler(ctx, messageRouter, mediaTranscoder)
173182 schemeHandler.setAssets(assets.WebUIAssets)
174183
175184 schemeFactory := purecef.NewSchemeHandlerFactory(schemeHandler)
@@ -199,6 +208,36 @@ func wireEngine(
199208 return eng, nil
200209 }
201210
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+
202241 // getPrimaryMonitor returns the primary GDK monitor, or nil if unavailable.
203242 func getPrimaryMonitor() *gdk.Monitor {
204243 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 = savedArgs61 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.MediaTranscoder144 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 = tc154 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 = factory175 eng.factory = factory
168 eng.pool = pool176 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, nil208 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) {
7777
7878 var transcodingHandler purecef.ResourceRequestHandler
7979 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+ })
8186 }
8287
8388 handlers := &handlerSet{
@@ -77,7 +77,12 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) {
77 77
78 var transcodingHandler purecef.ResourceRequestHandler78 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 (
1414 "path/filepath"
1515 "strings"
1616 "sync"
17+ "time"
1718 "unsafe"
1819
1920 purecef "github.com/bnema/purego-cef/cef"
@@ -39,6 +40,7 @@ const (
3940 type dumbSchemeHandler struct {
4041 ctx context.Context
4142 messageRouter *MessageRouter
43+ transcoder port.MediaTranscoder
4244 assets embed.FS
4345 assetsSet bool
4446 assetDir string
@@ -48,11 +50,12 @@ type dumbSchemeHandler struct {
4850 }
4951
5052 // 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 {
5254 log := logging.FromContext(ctx)
5355 return &dumbSchemeHandler{
5456 ctx: ctx,
5557 messageRouter: router,
58+ transcoder: transcoder,
5659 assetDir: "webui",
5760 logger: log.With().Str("component", "scheme-handler").Logger(),
5861 hwSurveyor: env.NewHardwareSurveyor(),
@@ -130,11 +133,66 @@ func (h *dumbSchemeHandler) handleAPI(method, path string, request purecef.Reque
130133 case path == "/api/config/default" && (method == "" || strings.EqualFold(method, "GET")):
131134 return h.handleConfigAPI(config.DefaultConfig())
132135
136+ case path == "/api/transcode" && (method == "" || strings.EqualFold(method, "GET")):
137+ return h.handleTranscodeAPI(request)
138+
133139 default:
134140 return h.newJSONResourceHandler(http.StatusNotFound, map[string]string{"error": "not found"})
135141 }
136142 }
137143
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+
138196 // handleConfigAPI returns a JSON response with the given config.
139197 func (h *dumbSchemeHandler) handleConfigAPI(cfg *config.Config) purecef.ResourceHandler {
140198 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.Context41 ctx context.Context
41 messageRouter *MessageRouter42 messageRouter *MessageRouter
43+ transcoder port.MediaTranscoder
42 assets embed.FS44 assets embed.FS
43 assetsSet bool45 assetsSet bool
44 assetDir string46 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.HardwareInfo198 var hwInfo *port.HardwareInfo
M internal/infrastructure/cef/transcoding_handler.go +174 -30
@@ -2,6 +2,8 @@ package cef
22
33 import (
44 "context"
5+ "io"
6+ "strings"
57 "sync"
68 "time"
79 "unsafe"
@@ -10,6 +12,8 @@ import (
1012
1113 "github.com/bnema/dumber/internal/application/port"
1214 "github.com/bnema/dumber/internal/infrastructure/transcoder"
15+ "github.com/bnema/dumber/internal/logging"
16+ "github.com/rs/zerolog"
1317 )
1418
1519 // ---------------------------------------------------------------------------
@@ -32,6 +36,8 @@ type transcodingResourceHandler struct {
3236 session port.TranscodeSession
3337 ctx context.Context
3438 cancel context.CancelFunc
39+ logf func() zerolog.Logger
40+ totalBytes int64
3541 }
3642
3743 // Open starts the transcode session asynchronously. CEF will call
@@ -40,14 +46,28 @@ func (rh *transcodingResourceHandler) Open(_ purecef.Request, handleRequest unsa
4046 // Signal async handling: set handleRequest = 0.
4147 *(*int32)(handleRequest) = 0
4248
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+
4355 go func() {
4456 session, err := rh.transcoder.Start(rh.ctx, rh.sourceURL, rh.headers)
4557 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")
4662 // Let CEF know we failed — Cancel will be called.
4763 rh.cancel()
4864 return
4965 }
5066 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")
5171 callback.Cont()
5272 }()
5373
@@ -60,6 +80,8 @@ func (rh *transcodingResourceHandler) GetResponseHeaders(response purecef.Respon
6080 response.SetStatusText("OK")
6181 response.SetMimeType("video/webm")
6282 response.SetHeaderByName("Accept-Ranges", "none", 1)
83+ response.SetHeaderByName("Access-Control-Allow-Origin", "*", 1)
84+ response.SetHeaderByName("Cache-Control", "no-store", 1)
6385 // Streaming — unknown length.
6486 *(*int64)(responseLength) = -1
6587 }
@@ -76,10 +98,29 @@ func (rh *transcodingResourceHandler) Read(
7698 dst := unsafe.Slice((*byte)(dataOut), int(bytesToRead))
7799 n, err := rh.session.Read(dst)
78100 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+ }
79108 *(*int32)(bytesRead) = int32(n)
80109 return 1
81110 }
82111 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+ }
83124 return 0 // EOF or error
84125 }
85126 return 0
@@ -87,6 +128,10 @@ func (rh *transcodingResourceHandler) Read(
87128
88129 // Cancel aborts the transcode session and releases resources.
89130 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")
90135 if rh.session != nil {
91136 rh.session.Close()
92137 }
@@ -112,12 +157,14 @@ func (rh *transcodingResourceHandler) Skip(_ int64, _ unsafe.Pointer, _ purecef.
112157
113158 // ---------------------------------------------------------------------------
114159 // 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
116162 // ---------------------------------------------------------------------------
117163
118164 type transcodingRequestHandler struct {
119165 transcoder port.MediaTranscoder
120166 transcodableURLs sync.Map // url string -> requestInfo
167+ ctxf func() context.Context
121168 }
122169
123170 type requestInfo struct {
@@ -125,69 +172,112 @@ type requestInfo struct {
125172 }
126173
127174 // 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 {
131178 return purecef.NewResourceRequestHandler(&transcodingRequestHandler{
132179 transcoder: tc,
180+ ctxf: ctxf,
133181 })
134182 }
135183
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.
140188 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 {
142190 return 0
143191 }
144192 mimeType := response.GetMimeType()
145- if !transcoder.IsProprietaryVideoMIME(mimeType) {
146- return 0
147- }
193+ url := request.GetURL()
148194
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")
154207 }
155- if auth := request.GetHeaderByName("Authorization"); auth != "" {
156- headers["Authorization"] = auth
208+
209+ if isAlreadyOpen {
210+ return 0
157211 }
158- if referer := request.GetHeaderByName("Referer"); referer != "" {
159- headers["Referer"] = referer
212+
213+ if !isProprietary && !isManifest {
214+ return 0
160215 }
161216
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")
164229
165230 return 1 // restart request
166231 }
167232
168233 // 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.
170235 func (h *transcodingRequestHandler) GetResourceHandler(_ purecef.Browser, _ purecef.Frame, request purecef.Request) purecef.ResourceHandler {
171236 if request == nil {
172237 return nil
173238 }
174239 url := request.GetURL()
175240 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 {
181264 return nil
182265 }
183266
184267 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")
185274 return purecef.NewResourceHandler(&transcodingResourceHandler{
186275 transcoder: h.transcoder,
187- sourceURL: url,
276+ sourceURL: sourceURL,
188277 headers: info.headers,
189278 ctx: ctx,
190279 cancel: cancel,
280+ logf: h.logf,
191281 })
192282 }
193283
@@ -213,3 +303,57 @@ func (h *transcodingRequestHandler) OnResourceLoadComplete(_ purecef.Browser, _
213303
214304 func (h *transcodingRequestHandler) OnProtocolExecution(_ purecef.Browser, _ purecef.Frame, _ purecef.Request, _ unsafe.Pointer) {
215305 }
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.TranscodeSession36 session port.TranscodeSession
33 ctx context.Context37 ctx context.Context
34 cancel context.CancelFunc38 cancel context.CancelFunc
39+ logf func() zerolog.Logger
40+ totalBytes int64
35 }41 }
36 42
37 // Open starts the transcode session asynchronously. CEF will call43 // 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) = 047 *(*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 return64 return
49 }65 }
50 rh.session = session66 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) = -186 *(*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 1109 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 error124 return 0 // EOF or error
84 }125 }
85 return 0126 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 intercepts159 // transcodingRequestHandler — ResourceRequestHandler that intercepts
115-// proprietary video responses and restarts them through the transcoder160+// 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.MediaTranscoder165 transcoder port.MediaTranscoder
120 transcodableURLs sync.Map // url string -> requestInfo166 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 detects174 // newTranscodingRequestHandler creates a ResourceRequestHandler that detects
128-// proprietary video MIME types and restarts those requests through a GPU175+// 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 matches184+// 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 info185+// matches a proprietary video format or a streaming manifest entrypoint, it
138-// and returns 1 to restart the request — at which point GetResourceHandler186+// 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 0190 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 transcoder195+ 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"] = cookie199+ 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"] = auth209+ if isAlreadyOpen {
210+ return 0
157 }211 }
158- if referer := request.GetHeaderByName("Referer"); referer != "" {212+
159- headers["Referer"] = referer213+ 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 request230 return 1 // restart request
166 }231 }
167 232
168 // GetResourceHandler returns a transcoding ResourceHandler for URLs that were233 // 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 nil237 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 nil242+ 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 nil264 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 @@
11 package cef
22
33 import (
4+ "strings"
5+
46 purecef "github.com/bnema/purego-cef/cef"
57
68 "github.com/bnema/dumber/internal/application/port"
9+ "github.com/bnema/dumber/internal/infrastructure/transcoder"
710 "github.com/bnema/dumber/internal/logging"
811 )
912
@@ -312,7 +315,22 @@ func (h *handlerSet) OnStatusMessage(_ purecef.Browser, value string) {
312315 }
313316 }
314317
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+ }
316334 return 0
317335 }
318336
@@ -557,9 +575,17 @@ func (h *handlerSet) OnOpenUrlfromTab(_ purecef.Browser, _ purecef.Frame, _ stri
557575 }
558576
559577 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,
562580 ) 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+ }
563589 if h.transcodingHandler != nil {
564590 return h.transcodingHandler
565591 }
@@ -1,9 +1,12 @@
1 package cef1 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 0334 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.transcodingHandler590 return h.transcodingHandler
565 }591 }
M internal/infrastructure/config/defaults.go +12 -0
@@ -70,6 +70,12 @@ const (
7070 defaultCEFWindowlessFrameRate = 60 // OSR frame rate for CEF
7171 defaultCEFManualPumpIntervalMs = 10 // CEF manual message pump interval
7272
73+ // Transcoding defaults
74+ defaultTranscodingEnabled = true
75+ defaultTranscodingHWAccel = "auto"
76+ defaultTranscodingMaxConcurrent = 3
77+ defaultTranscodingQuality = "medium"
78+
7379 // Skia threading defaults (0 = unset, -1 = unset for GPU threads)
7480 defaultSkiaCPUPaintingThreads = 0
7581 defaultSkiaGPUPaintingThreads = -1 // -1 means unset; 0 would disable GPU tile painting
@@ -315,6 +321,12 @@ func DefaultConfig() *Config {
315321 Downloads: DownloadsConfig{
316322 Path: "", // Empty = use XDG_DOWNLOAD_DIR or ~/Downloads
317323 },
324+ Transcoding: TranscodingConfig{
325+ Enabled: defaultTranscodingEnabled,
326+ HWAccel: defaultTranscodingHWAccel,
327+ MaxConcurrent: defaultTranscodingMaxConcurrent,
328+ Quality: defaultTranscodingQuality,
329+ },
318330 }
319331 }
320332
@@ -70,6 +70,12 @@ const (
70 defaultCEFWindowlessFrameRate = 60 // OSR frame rate for CEF70 defaultCEFWindowlessFrameRate = 60 // OSR frame rate for CEF
71 defaultCEFManualPumpIntervalMs = 10 // CEF manual message pump interval71 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 = 080 defaultSkiaCPUPaintingThreads = 0
75 defaultSkiaGPUPaintingThreads = -1 // -1 means unset; 0 would disable GPU tile painting81 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 ~/Downloads322 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) {
1111 assert.Equal(t, "info", cfg.Logging.Level)
1212 assert.False(t, cfg.Logging.CaptureGTKLogs)
1313 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)
1418
1519 // Engine defaults (replaces old Performance/Privacy sections)
1620 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) {
607607 m.viper.SetDefault("engine.webkit.network_process_memory_conservative_threshold", wk.NetworkProcessMemoryConservativeThreshold)
608608 m.viper.SetDefault("engine.webkit.network_process_memory_strict_threshold", wk.NetworkProcessMemoryStrictThreshold)
609609
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)
615615 }
616616
617617 // 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 @@
11 package config
22
33 import (
4+ "os"
5+ "path/filepath"
46 "testing"
57
68 "github.com/spf13/viper"
79 "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
811 )
912
1013 func TestSetEngineDefaults(t *testing.T) {
@@ -24,6 +27,12 @@ func TestSetEngineDefaults(t *testing.T) {
2427 assert.Equal(t, "auto", mgr.viper.GetString("engine.webkit.gl_rendering_mode"))
2528 assert.False(t, mgr.viper.GetBool("engine.webkit.disable_dmabuf_renderer"))
2629
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+
2736 // Old sections should NOT be registered as defaults
2837 assert.Empty(t, mgr.viper.GetString("privacy.cookie_policy"))
2938 assert.Empty(t, mgr.viper.GetString("rendering.mode"))
@@ -39,3 +48,34 @@ func TestNormalizeConfig_EngineCookiePolicy(t *testing.T) {
3948
4049 assert.Equal(t, CookiePolicyAlways, cfg.Engine.CookiePolicy)
4150 }
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 config1 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 defaults36 // 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 {
375375 // Default: "auto" (tries VAAPI first, then CUDA).
376376 HWAccel string `mapstructure:"hwaccel" yaml:"hwaccel" toml:"hwaccel"`
377377 // MaxConcurrent is the maximum number of simultaneous transcode sessions.
378- // Default: 2.
378+ // Default: 3.
379379 MaxConcurrent int `mapstructure:"max_concurrent" yaml:"max_concurrent" toml:"max_concurrent"`
380380 // Quality controls the encode quality preset: "low", "medium", or "high".
381381 // 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) {
4343 }
4444 }
4545
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+
4653 t.Logf("Found %d sections, all sorted", len(sections))
4754
4855 // 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 debugging55 // 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) (
100100 return nil, errors.New("failed to create output audio stream")
101101 }
102102
103- outStreamWrap := (*ffmpeg.Stream)(outStreamPtr)
103+ outStreamWrap := ffmpeg.WrapStream(outStreamPtr)
104104 outStreamIdx := int(outStreamWrap.Index())
105105
106106 // Copy encoder parameters to the output stream's codecpar.
@@ -271,7 +271,7 @@ func (a *audioTranscoder) processPacket(pkt, outFmtCtx unsafe.Pointer) error {
271271
272272 // Rescale timestamps from encoder time base to output stream time base.
273273 outStream := ffmpeg.FmtCtxStream(outFmtCtx, a.outStream)
274- outStreamWrap := (*ffmpeg.Stream)(outStream)
274+ outStreamWrap := ffmpeg.WrapStream(outStream)
275275 ffmpeg.PacketRescaleTs(encPkt, ffmpeg.CodecCtxTimeBase(a.encoder), outStreamWrap.TimeBase())
276276
277277 if wret := ffmpeg.InterleavedWriteFrame(outFmtCtx, encPkt); wret < 0 {
@@ -341,7 +341,7 @@ func (a *audioTranscoder) flush(outFmtCtx unsafe.Pointer, logger zerolog.Logger)
341341 }
342342 pktSetStreamIndex(encPkt, int32(a.outStream))
343343 outStream := ffmpeg.FmtCtxStream(outFmtCtx, a.outStream)
344- outStreamWrap := (*ffmpeg.Stream)(outStream)
344+ outStreamWrap := ffmpeg.WrapStream(outStream)
345345 ffmpeg.PacketRescaleTs(encPkt, ffmpeg.CodecCtxTimeBase(a.encoder), outStreamWrap.TimeBase())
346346 ffmpeg.InterleavedWriteFrame(outFmtCtx, encPkt)
347347 ffmpeg.PacketUnref(encPkt)
@@ -359,7 +359,7 @@ func (a *audioTranscoder) flush(outFmtCtx unsafe.Pointer, logger zerolog.Logger)
359359 }
360360 pktSetStreamIndex(encPkt, int32(a.outStream))
361361 outStream := ffmpeg.FmtCtxStream(outFmtCtx, a.outStream)
362- outStreamWrap := (*ffmpeg.Stream)(outStream)
362+ outStreamWrap := ffmpeg.WrapStream(outStream)
363363 ffmpeg.PacketRescaleTs(encPkt, ffmpeg.CodecCtxTimeBase(a.encoder), outStreamWrap.TimeBase())
364364 ffmpeg.InterleavedWriteFrame(outFmtCtx, encPkt)
365365 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
22
33 import (
44 "mime"
5+ "net/url"
6+ "path"
57 "strings"
68 )
79
@@ -29,6 +31,29 @@ var openVideoMIMEs = map[string]bool{
2931 "video/ogg": true, // Theora + Vorbis
3032 }
3133
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+
3257 // IsProprietaryVideoMIME returns true if the MIME type indicates a video
3358 // format that typically carries proprietary codecs requiring transcoding.
3459 func IsProprietaryVideoMIME(contentType string) bool {
@@ -43,6 +68,92 @@ func IsOpenVideoMIME(contentType string) bool {
4368 return openVideoMIMEs[mediaType]
4469 }
4570
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+
46157 // parseMediaType extracts the MIME type from a Content-Type header value,
47158 // stripping parameters like charset or codecs.
48159 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 + Vorbis31 "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 video57 // 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) {
8484 })
8585 }
8686 }
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) {
2727 }
2828
2929 // ---------------------------------------------------------------------------
30-// AVFrame field offsets (FFmpeg 7.x, libavutil 60)
30+// AVFrame field offsets (FFmpeg 8.x, libavutil 60)
3131 // 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)
3434 // ...
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*)
4344 // ---------------------------------------------------------------------------
4445
4546 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
5254 )
5355
5456 func frameWidth(f unsafe.Pointer) int32 {
@@ -83,6 +85,10 @@ func frameSetFormat(f unsafe.Pointer, v int32) {
8385 *(*int32)(unsafe.Add(f, offsetFrameFormat)) = v
8486 }
8587
88+func frameSetHWFramesCtx(f unsafe.Pointer, v unsafe.Pointer) {
89+ *(*unsafe.Pointer)(unsafe.Add(f, offsetFrameHWFramesCtx)) = v
90+}
91+
8692 func framePts(f unsafe.Pointer) int64 {
8793 return *(*int64)(unsafe.Add(f, offsetFramePts))
8894 }
@@ -101,14 +107,14 @@ func frameSetSampleRate(f unsafe.Pointer, v int32) {
101107
102108 // ---------------------------------------------------------------------------
103109 // AVCodecContext additional offsets
104-// flags at offset 72 (int32, but stored as uint32 in practice)
110+// Verified against the installed libavcodec headers via offsetof().
105111 // ---------------------------------------------------------------------------
106112
107113 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)
112118 )
113119
114120 // AV_CODEC_FLAG_GLOBAL_HEADER indicates the codec uses global headers
@@ -136,7 +142,7 @@ func codecCtxSetBitRate(ctx unsafe.Pointer, v int64) {
136142 func codecCtxCopyChLayout(dst, src unsafe.Pointer) {
137143 dstLayout := unsafe.Add(dst, offsetCodecCtxChLayout)
138144 srcLayout := unsafe.Add(src, offsetCodecCtxChLayout)
139- // AVChannelLayout is 24 bytes in FFmpeg 7.x.
145+ // AVChannelLayout is 24 bytes in FFmpeg 8.x.
140146 copy(
141147 unsafe.Slice((*byte)(dstLayout), 24),
142148 unsafe.Slice((*byte)(srcLayout), 24),
@@ -149,7 +155,7 @@ func codecCtxCopyChLayout(dst, src unsafe.Pointer) {
149155
150156 const (
151157 offsetCodecParSampleRate = 152
152- offsetCodecParChLayout = 56 // AVChannelLayout (24 bytes) at offset 56
158+ offsetCodecParChLayout = 128 // AVChannelLayout (24 bytes)
153159 )
154160
155161 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 26835+// width at offset 104
36-// height at offset 27236+// height at offset 108
37-// nb_samples at offset 27637+// nb_samples at offset 112
38-// format at offset 28038+// format at offset 116
39-// pts at offset 32839+// pts at offset 136
40-// ...40+// sample_rate at offset 180
41-// sample_rate at offset 43241+// 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 = 26847+ offsetFrameWidth = 104
47- offsetFrameHeight = 27248+ offsetFrameHeight = 108
48- offsetFrameNbSamples = 27649+ offsetFrameNbSamples = 112
49- offsetFrameFormat = 28050+ offsetFrameFormat = 116
50- offsetFramePts = 32851+ offsetFrameHWFramesCtx = 328
51- offsetFrameSampleRate = 43252+ 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)) = v85 *(*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 offsets109 // 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 = 72114+ offsetCodecCtxFlags = 64
109- offsetCodecCtxGopSize = 120115+ offsetCodecCtxGopSize = 332
110- offsetCodecCtxBitRate = 28116+ 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 headers120 // 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 = 152157 offsetCodecParSampleRate = 152
152- offsetCodecParChLayout = 56 // AVChannelLayout (24 bytes) at offset 56158+ 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 (
66 "fmt"
77 "io"
88 "net/http"
9+ "sort"
910 "strings"
1011 "unsafe"
1112
@@ -29,6 +30,7 @@ const avioBufSize = 32 * 1024
2930 // Opus via libopus, and muxes the result into WebM written to an
3031 // io.PipeWriter.
3132 type pipeline struct {
33+ sessionID string
3234 hwCaps port.HWCapabilities
3335 sourceURL string
3436 headers map[string]string
@@ -53,8 +55,9 @@ var httpClient = &http.Client{
5355 },
5456 }
5557
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 {
5759 return &pipeline{
60+ sessionID: sessionID,
5861 hwCaps: hwCaps,
5962 sourceURL: sourceURL,
6063 headers: headers,
@@ -69,8 +72,21 @@ func newPipeline(hwCaps port.HWCapabilities, sourceURL string, headers map[strin
6972 func (p *pipeline) run(ctx context.Context) {
7073 err := p.doRun(ctx)
7174 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+ }
7284 p.pw.CloseWithError(err)
7385 } else {
86+ p.logger.Debug().
87+ Str("session_id", p.sessionID).
88+ Str("source_url", p.sourceURL).
89+ Msg("transcode pipeline completed")
7490 p.pw.Close()
7591 }
7692 }
@@ -78,80 +94,26 @@ func (p *pipeline) run(ctx context.Context) {
7894 // doRun contains the actual pipeline logic. Errors are returned, and
7995 // the caller (run) closes the pipe writer accordingly.
8096 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)
9199 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
137101 }
138- defer ffmpeg.FormatCloseInput(unsafe.Pointer(&inFmtCtx))
102+ defer closeInput()
139103
140104 // --- Step 4: Find stream info ---
141105 if ret := ffmpeg.FormatFindStreamInfo(inFmtCtx, nil); ret < 0 {
142106 return fmt.Errorf("find stream info: %d", ret)
143107 }
144108
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
151113 }
152114
153- videoStream := ffmpeg.FmtCtxStream(inFmtCtx, int(videoIdx))
154- videoStreamWrap := (*ffmpeg.Stream)(videoStream)
115+ videoStream := ffmpeg.FmtCtxStream(inFmtCtx, videoIdx)
116+ videoStreamWrap := ffmpeg.WrapStream(videoStream)
155117 videoCodecPar := videoStreamWrap.Codecpar()
156118
157119 // --- Step 6: Open video decoder ---
@@ -207,7 +169,7 @@ func (p *pipeline) doRun(ctx context.Context) error {
207169 if outVideoStream == nil {
208170 return errors.New("failed to create output video stream")
209171 }
210- outVideoStreamWrap := (*ffmpeg.Stream)(outVideoStream)
172+ outVideoStreamWrap := ffmpeg.WrapStream(outVideoStream)
211173 outVideoIdx := int(outVideoStreamWrap.Index())
212174
213175 if ret := ffmpeg.CodecParametersFromContext(outVideoStreamWrap.Codecpar(), vidEncCtx); ret < 0 {
@@ -217,8 +179,8 @@ func (p *pipeline) doRun(ctx context.Context) error {
217179 // --- Step 11b: Add audio output stream (if audio present) ---
218180 var audioTx *audioTranscoder
219181 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)
222184 audioCodecPar := audioStreamWrap.Codecpar()
223185
224186 var txErr error
@@ -271,7 +233,7 @@ func (p *pipeline) doRun(ctx context.Context) error {
271233 break
272234 }
273235
274- streamIdx := pktStreamIndex(pkt)
236+ streamIdx := int(pktStreamIndex(pkt))
275237
276238 switch {
277239 case streamIdx == videoIdx:
@@ -311,7 +273,7 @@ func (p *pipeline) doRun(ctx context.Context) error {
311273 }
312274 pktSetStreamIndex(encPkt, int32(outVideoIdx))
313275 outVidStream := ffmpeg.FmtCtxStream(outFmtCtx, outVideoIdx)
314- outVidStreamWrap := (*ffmpeg.Stream)(outVidStream)
276+ outVidStreamWrap := ffmpeg.WrapStream(outVidStream)
315277 ffmpeg.PacketRescaleTs(encPkt, ffmpeg.CodecCtxTimeBase(vidEncCtx), outVidStreamWrap.TimeBase())
316278 ffmpeg.InterleavedWriteFrame(outFmtCtx, encPkt)
317279 ffmpeg.PacketUnref(encPkt)
@@ -330,6 +292,235 @@ func (p *pipeline) doRun(ctx context.Context) error {
330292 return nil
331293 }
332294
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+
333524 // openVideoDecoder creates and opens a decoder context for the video stream.
334525 // It prefers hardware decoders (h264_vaapi, h264_cuvid) based on hwCaps,
335526 // falling back to the generic software decoder.
@@ -394,28 +585,50 @@ func (p *pipeline) openVideoEncoder(decCtx unsafe.Pointer) (hwDeviceCtx unsafe.P
394585 return nil, nil, fmt.Errorf("create HW device: %w", hwErr)
395586 }
396587
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
400592 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
405609 }
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")
406617 }
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)
410622 }
623+ return nil, nil, errors.New("no usable encoder codec found")
624+}
411625
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)
413628 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")
416630 }
417631
418- // Copy dimensions and framerate from decoder.
419632 width := ffmpeg.CodecCtxWidth(decCtx)
420633 height := ffmpeg.CodecCtxHeight(decCtx)
421634 ffmpeg.CodecCtxSetWidth(encCtx, width)
@@ -428,77 +641,50 @@ func (p *pipeline) openVideoEncoder(decCtx unsafe.Pointer) (hwDeviceCtx unsafe.P
428641 ffmpeg.CodecCtxSetFramerate(encCtx, framerate)
429642 ffmpeg.CodecCtxSetTimeBase(encCtx, ffmpeg.AVRational{Num: framerate.Den, Den: framerate.Num})
430643
431- // Set pixel format based on the API.
432644 if p.hwCaps.API == "vaapi" {
433645 ffmpeg.CodecCtxSetPixFmt(encCtx, int32(ffmpeg.PixelFormatPixFmtVaapi))
434646 } else {
435- // CUDA/NVENC uses NV12.
436647 ffmpeg.CodecCtxSetPixFmt(encCtx, int32(ffmpeg.PixelFormatPixFmtNv12))
437648 }
438649
439- // Set GOP size to 2 seconds worth of frames.
440650 gopSize := int32(framerate.Num / framerate.Den * 2)
441651 if gopSize <= 0 {
442652 gopSize = 60
443653 }
444654 codecCtxSetGopSize(encCtx, gopSize)
445-
446- // Set quality/bitrate based on quality parameter.
447655 p.applyQualityPreset(encCtx, encoderName, width, height)
448656
449- // Attach HW device context. Use BufferRef to create a new reference
450- // since the encoder will take ownership of one ref.
451657 hwDeviceRef := ffmpeg.BufferRef(hwDeviceCtx)
452658 if hwDeviceRef == nil {
453659 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")
456661 }
457662 ffmpeg.CodecCtxSetHwDeviceCtx(encCtx, hwDeviceRef)
458663
459- // For VAAPI encoders, we need a HW frames context.
460664 if p.hwCaps.API == "vaapi" {
461665 framesRef := ffmpeg.HWFrameCtxAlloc(hwDeviceCtx)
462666 if framesRef == nil {
463667 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")
495669 }
496670
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+
497684 if ret := ffmpeg.HWFrameCtxInit(framesRef); ret < 0 {
498685 ffmpeg.BufferUnref(&framesRef)
499686 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)
502688 }
503689
504690 ffmpeg.CodecCtxSetHwFramesCtx(encCtx, framesRef)
@@ -506,11 +692,10 @@ func (p *pipeline) openVideoEncoder(decCtx unsafe.Pointer) (hwDeviceCtx unsafe.P
506692
507693 if ret := ffmpeg.CodecOpen2(encCtx, enc, nil); ret < 0 {
508694 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)
511696 }
512697
513- return hwDeviceCtx, encCtx, nil
698+ return encCtx, nil
514699 }
515700
516701 // applyQualityPreset sets bitrate and encoder options based on the
@@ -582,12 +767,18 @@ func (p *pipeline) transcodeVideoPacket(pkt, frame, encPkt, decCtx, encCtx, outF
582767 // encodeAndWriteVideoFrame sends a decoded frame to the encoder and
583768 // writes any resulting packets to the output.
584769 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+ }
589780
590- ret := ffmpeg.CodecSendFrame(encCtx, frame)
781+ ret := ffmpeg.CodecSendFrame(encCtx, encFrame)
591782 if ret < 0 {
592783 return fmt.Errorf("video encode send frame: %d", ret)
593784 }
@@ -601,7 +792,7 @@ func (p *pipeline) encodeAndWriteVideoFrame(frame, encPkt, encCtx, outFmtCtx uns
601792 pktSetStreamIndex(encPkt, int32(outVideoIdx))
602793
603794 outVidStream := ffmpeg.FmtCtxStream(outFmtCtx, outVideoIdx)
604- outVidStreamWrap := (*ffmpeg.Stream)(outVidStream)
795+ outVidStreamWrap := ffmpeg.WrapStream(outVidStream)
605796 ffmpeg.PacketRescaleTs(encPkt, ffmpeg.CodecCtxTimeBase(encCtx), outVidStreamWrap.TimeBase())
606797
607798 if wret := ffmpeg.InterleavedWriteFrame(outFmtCtx, encPkt); wret < 0 {
@@ -614,5 +805,50 @@ func (p *pipeline) encodeAndWriteVideoFrame(frame, encPkt, encCtx, outFmtCtx uns
614805 return nil
615806 }
616807
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+
617853 // averrorEOF is the AVERROR_EOF constant.
618854 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 an30 // 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.HWCapabilities34 hwCaps port.HWCapabilities
33 sourceURL string35 sourceURL string
34 headers map[string]string36 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, and94 // 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 *audioTranscoder180 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 error186 var txErr error
@@ -271,7 +233,7 @@ func (p *pipeline) doRun(ctx context.Context) error {
271 break233 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 nil292 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.Pointer589+ height := ffmpeg.CodecCtxHeight(decCtx)
399- var encoderName string590+
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 = name595+ continue
404- break596+ }
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 = 60652 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, nil698+ return encCtx, nil
514 }699 }
515 700
516 // applyQualityPreset sets bitrate and encoder options based on the701 // 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 and767 // 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 encoder771+ var cleanup func()
587- // with hw_device_ctx set should handle format conversion internally772+ 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 nil805 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
111111 return nil, fmt.Errorf("transcoder: %w", err)
112112 }
113113
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)
115115
116116 t.logger.Info().
117117 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 @@
11 package logging
22
33 import (
4+ "errors"
45 "io"
56 "os"
67 "path/filepath"
8+ "sync"
79 "time"
810
911 "github.com/rs/zerolog"
@@ -105,8 +107,9 @@ func NewWithFile(cfg Config, fileCfg FileConfig) (zerolog.Logger, func(), error)
105107 return zerolog.Logger{}, nil, err
106108 }
107109
108- writers = append(writers, file)
109- cleanup = func() { _ = file.Close() }
110+ sessionWriter := newSessionFileWriter(file)
111+ writers = append(writers, sessionWriter)
112+ cleanup = func() { _ = sessionWriter.Close() }
110113 }
111114
112115 // Fallback to discard if no writers configured
@@ -123,6 +126,51 @@ func NewWithFile(cfg Config, fileCfg FileConfig) (zerolog.Logger, func(), error)
123126 return ctx.Logger(), cleanup, nil
124127 }
125128
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+
126174 // NewFromEnv creates a logger based on environment variables
127175 // DUMBER_LOG_LEVEL: trace, debug, info, warn, error (default: info)
128176 // DUMBER_LOG_FORMAT: json, console (default: console)
@@ -1,9 +1,11 @@
1 package logging1 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, err107 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 configured115 // 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, nil126 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 variables174 // 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+}

Keyboard shortcuts

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

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

Branches 8

Tags 42

↑↓ moveenter openesc close