detached 37f0ef7

fix: address all 29 CodeRabbit findings across transcoder, CEF, config, UI

Transcoder (10 findings):
- Fix inconsistent FFmpeg version comments (7.x→8.x), add validated version block
- Add SwrContext explicit channel layout config (in_chlayout/out_chlayout)
- Add safety comments for unavoidable unsafe pointer casts in audio frame data
- Use local config copy to prevent shared struct mutation race
- Make audio processing errors non-fatal (match non-fatal init behavior)
- Add 10 edge case tests for ParseSyntheticTranscodeURL
- Add retry loop for io.Reader n=0/err=nil edge case in read callback
- Refactor pool.closeAll() to release lock before closing sessions
- Remove unused error return from audio flush()

CEF (12 findings):
- Fix double-patching of Element.prototype.attachShadow (marker + chaining)
- Self-clearing setInterval in Reddit video patch (30s timeout)
- Gate videoDiagnosticJS behind DUMBER_VIDEO_DIAG env var
- Propagate parent context in handleTranscodeAPI and transcoding handler
- Add nil receiver guard to glLoader.Close()
- Replace math.Abs with assert.InDelta in zoom test
- Replace busy-wait polling with channel signal in closeActiveWebViews
- Extract console message filter markers to package constants
- Add CORS comment documenting why wildcard is needed
- Reduce lock scope in uploadPopupTexture (copy-release-GL-reacquire)

Config/UI/WebUI (7 findings):
- Validate HandlerDeps.SaveConfig in deps.Validate()
- Add try/catch error handling to vite-plugin-pages fetch bridge
- Extract setTranscodingDefaults from setEngineDefaults
- Make writer_test assertion specific to [transcoding] section
- Add explicit null check in ConfigPage.isPerformanceSupported
- Propagate context to textureFromBytesOnGTK (5 call sites)
- Document local replace directives in go.mod
commit
37f0ef7143388e63138a277b049369a4bef3b86c
author
brice · · 2026-03-25 05:48 UTC
committer
brice · · 2026-03-28 04:39 UTC
parent
aeff368
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
20 files · +358 -122
M internal/infrastructure/cef/content_injector.go +36 -13
@@ -3,6 +3,7 @@ package cef
33 import (
44 "context"
55 "fmt"
6+ "os"
67 "strings"
78 "sync"
89
@@ -118,7 +119,12 @@ const videoDiagnosticJS = `(function(){
118119 }
119120
120121 // Patch attachShadow to automatically observe new shadow roots.
121- var origAttach = Element.prototype.attachShadow;
122+ // Use a marker to avoid double-patching if redditDirectVideoJS also patches.
123+ var origAttach = Element.prototype.__dumberOrigAttachShadow || Element.prototype.attachShadow;
124+ if (!Element.prototype.__dumberPatched) {
125+ Element.prototype.__dumberOrigAttachShadow = origAttach;
126+ Element.prototype.__dumberPatched = true;
127+ }
122128 Element.prototype.attachShadow = function(opts) {
123129 var sr = origAttach.call(this, opts);
124130 console.warn(tag, 'shadowRoot attached on', this.tagName.toLowerCase());
@@ -355,15 +361,22 @@ const redditDirectVideoJS = `(function(){
355361 attributeFilter: ['src']
356362 });
357363
358- var origAttach = Element.prototype.attachShadow;
364+ // Chain to existing attachShadow patch (e.g. videoDiagnosticJS) instead of overwriting.
365+ var prevAttach = Element.prototype.attachShadow;
359366 Element.prototype.attachShadow = function(opts) {
360- var sr = origAttach.call(this, opts);
367+ var sr = prevAttach.call(this, opts);
361368 patchTree(sr);
362369 return sr;
363370 };
364371
365372 patchTree(document);
366- setInterval(function() { patchTree(document); }, 1000);
373+ var pollCount = 0;
374+ var maxPolls = 30;
375+ var pollID = setInterval(function() {
376+ patchTree(document);
377+ pollCount++;
378+ if (pollCount >= maxPolls) clearInterval(pollID);
379+ }, 1000);
367380 })();`
368381
369382 // contentInjector implements port.ContentInjector for the CEF engine.
@@ -371,11 +384,12 @@ const redditDirectVideoJS = `(function(){
371384 // Thread-safe: InjectThemeCSS may be called from the UI thread while OnLoadEnd
372385 // fires on the CEF IO thread.
373386 type contentInjector struct {
374- mu sync.RWMutex
375- themeCSS string
376- findHighlightCSS string
377- engine *Engine
378- colorResolver port.ColorSchemeResolver
387+ mu sync.RWMutex
388+ themeCSS string
389+ findHighlightCSS string
390+ engine *Engine
391+ colorResolver port.ColorSchemeResolver
392+ videoDiagnosticsEnabled bool
379393 }
380394
381395 // setColorResolver updates the color scheme resolver used for dark mode detection.
@@ -386,10 +400,17 @@ func (ci *contentInjector) setColorResolver(resolver port.ColorSchemeResolver) {
386400 }
387401
388402 // newContentInjector creates a content injector wired to the given engine.
403+// Video diagnostics are enabled when DUMBER_VIDEO_DIAG=1 is set.
389404 func newContentInjector(engine *Engine, resolver port.ColorSchemeResolver) *contentInjector {
405+ diagEnabled := false
406+ switch strings.ToLower(strings.TrimSpace(os.Getenv("DUMBER_VIDEO_DIAG"))) {
407+ case "1", "true", "yes", "on":
408+ diagEnabled = true
409+ }
390410 return &contentInjector{
391- engine: engine,
392- colorResolver: resolver,
411+ engine: engine,
412+ colorResolver: resolver,
413+ videoDiagnosticsEnabled: diagEnabled,
393414 }
394415 }
395416
@@ -483,8 +504,10 @@ func (ci *contentInjector) onLoadEnd(wv *WebView) {
483504 wv.RunJavaScript(context.Background(), scrollbarAutoHideJS)
484505
485506 // Video playback diagnostic — logs video element state changes.
486- // TODO: remove once video playback is stable.
487- wv.RunJavaScript(context.Background(), videoDiagnosticJS)
507+ // Gated behind DUMBER_VIDEO_DIAG=1 environment variable.
508+ if ci.videoDiagnosticsEnabled {
509+ wv.RunJavaScript(context.Background(), videoDiagnosticJS)
510+ }
488511
489512 if strings.Contains(uri, "reddit.com") {
490513 wv.RunJavaScript(context.Background(), redditDirectVideoJS)
@@ -3,6 +3,7 @@ package cef
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6+ "os"
6 "strings"7 "strings"
7 "sync"8 "sync"
8 9
@@ -118,7 +119,12 @@ const videoDiagnosticJS = `(function(){
118 }119 }
119 120
120 // Patch attachShadow to automatically observe new shadow roots.121 // Patch attachShadow to automatically observe new shadow roots.
121- var origAttach = Element.prototype.attachShadow;122+ // Use a marker to avoid double-patching if redditDirectVideoJS also patches.
123+ var origAttach = Element.prototype.__dumberOrigAttachShadow || Element.prototype.attachShadow;
124+ if (!Element.prototype.__dumberPatched) {
125+ Element.prototype.__dumberOrigAttachShadow = origAttach;
126+ Element.prototype.__dumberPatched = true;
127+ }
122 Element.prototype.attachShadow = function(opts) {128 Element.prototype.attachShadow = function(opts) {
123 var sr = origAttach.call(this, opts);129 var sr = origAttach.call(this, opts);
124 console.warn(tag, 'shadowRoot attached on', this.tagName.toLowerCase());130 console.warn(tag, 'shadowRoot attached on', this.tagName.toLowerCase());
@@ -355,15 +361,22 @@ const redditDirectVideoJS = `(function(){
355 attributeFilter: ['src']361 attributeFilter: ['src']
356 });362 });
357 363
358- var origAttach = Element.prototype.attachShadow;364+ // Chain to existing attachShadow patch (e.g. videoDiagnosticJS) instead of overwriting.
365+ var prevAttach = Element.prototype.attachShadow;
359 Element.prototype.attachShadow = function(opts) {366 Element.prototype.attachShadow = function(opts) {
360- var sr = origAttach.call(this, opts);367+ var sr = prevAttach.call(this, opts);
361 patchTree(sr);368 patchTree(sr);
362 return sr;369 return sr;
363 };370 };
364 371
365 patchTree(document);372 patchTree(document);
366- setInterval(function() { patchTree(document); }, 1000);373+ var pollCount = 0;
374+ var maxPolls = 30;
375+ var pollID = setInterval(function() {
376+ patchTree(document);
377+ pollCount++;
378+ if (pollCount >= maxPolls) clearInterval(pollID);
379+ }, 1000);
367 })();`380 })();`
368 381
369 // contentInjector implements port.ContentInjector for the CEF engine.382 // contentInjector implements port.ContentInjector for the CEF engine.
@@ -371,11 +384,12 @@ const redditDirectVideoJS = `(function(){
371 // Thread-safe: InjectThemeCSS may be called from the UI thread while OnLoadEnd384 // Thread-safe: InjectThemeCSS may be called from the UI thread while OnLoadEnd
372 // fires on the CEF IO thread.385 // fires on the CEF IO thread.
373 type contentInjector struct {386 type contentInjector struct {
374- mu sync.RWMutex387+ mu sync.RWMutex
375- themeCSS string388+ themeCSS string
376- findHighlightCSS string389+ findHighlightCSS string
377- engine *Engine390+ engine *Engine
378- colorResolver port.ColorSchemeResolver391+ colorResolver port.ColorSchemeResolver
392+ videoDiagnosticsEnabled bool
379 }393 }
380 394
381 // setColorResolver updates the color scheme resolver used for dark mode detection.395 // setColorResolver updates the color scheme resolver used for dark mode detection.
@@ -386,10 +400,17 @@ func (ci *contentInjector) setColorResolver(resolver port.ColorSchemeResolver) {
386 }400 }
387 401
388 // newContentInjector creates a content injector wired to the given engine.402 // newContentInjector creates a content injector wired to the given engine.
403+// Video diagnostics are enabled when DUMBER_VIDEO_DIAG=1 is set.
389 func newContentInjector(engine *Engine, resolver port.ColorSchemeResolver) *contentInjector {404 func newContentInjector(engine *Engine, resolver port.ColorSchemeResolver) *contentInjector {
405+ diagEnabled := false
406+ switch strings.ToLower(strings.TrimSpace(os.Getenv("DUMBER_VIDEO_DIAG"))) {
407+ case "1", "true", "yes", "on":
408+ diagEnabled = true
409+ }
390 return &contentInjector{410 return &contentInjector{
391- engine: engine,411+ engine: engine,
392- colorResolver: resolver,412+ colorResolver: resolver,
413+ videoDiagnosticsEnabled: diagEnabled,
393 }414 }
394 }415 }
395 416
@@ -483,8 +504,10 @@ func (ci *contentInjector) onLoadEnd(wv *WebView) {
483 wv.RunJavaScript(context.Background(), scrollbarAutoHideJS)504 wv.RunJavaScript(context.Background(), scrollbarAutoHideJS)
484 505
485 // Video playback diagnostic — logs video element state changes.506 // Video playback diagnostic — logs video element state changes.
486- // TODO: remove once video playback is stable.507+ // Gated behind DUMBER_VIDEO_DIAG=1 environment variable.
487- wv.RunJavaScript(context.Background(), videoDiagnosticJS)508+ if ci.videoDiagnosticsEnabled {
509+ wv.RunJavaScript(context.Background(), videoDiagnosticJS)
510+ }
488 511
489 if strings.Contains(uri, "reddit.com") {512 if strings.Contains(uri, "reddit.com") {
490 wv.RunJavaScript(context.Background(), redditDirectVideoJS)513 wv.RunJavaScript(context.Background(), redditDirectVideoJS)
M internal/infrastructure/cef/engine.go +30 -19
@@ -34,6 +34,10 @@ type Engine struct {
3434 // activeWebViews tracks all live webviews for CSS broadcast.
3535 activeWebViews sync.Map // map[port.WebViewID]*WebView
3636
37+ // shutdownNotify is signalled when the active webview count reaches 0
38+ // during shutdown, replacing the busy-wait poll in closeActiveWebViews.
39+ shutdownNotify chan struct{}
40+
3741 multiThreadedMessageLoop bool
3842 manualPumpInterval int64
3943
@@ -123,8 +127,15 @@ func (e *Engine) registerWebView(wv *WebView) {
123127 }
124128
125129 // unregisterWebView removes a webview from the active tracking map.
130+// If the count reaches 0 and a shutdown is in progress, it signals shutdownNotify.
126131 func (e *Engine) unregisterWebView(wv *WebView) {
127132 e.activeWebViews.Delete(wv.id)
133+ if e.shutdownNotify != nil && e.activeWebViewCount() == 0 {
134+ select {
135+ case e.shutdownNotify <- struct{}{}:
136+ default:
137+ }
138+ }
128139 }
129140
130141 // internalSchemePath is the URI scheme used for internal app resources.
@@ -158,10 +169,7 @@ func (e *Engine) Close() error {
158169 return nil
159170 }
160171
161-const (
162- cefShutdownWaitStep = 10 * time.Millisecond
163- cefShutdownWaitTimeout = 2 * time.Second
164-)
172+const cefShutdownWaitTimeout = 2 * time.Second
165173
166174 func (e *Engine) activeWebViewCount() int {
167175 count := 0
@@ -195,25 +203,28 @@ func (e *Engine) closeActiveWebViews() {
195203 Int("count", len(webViews)).
196204 Msg("cef: closing active webviews before shutdown")
197205
206+ // Set up the shutdown notification channel before destroying webviews.
207+ e.shutdownNotify = make(chan struct{}, 1)
208+
198209 for _, wv := range webViews {
199210 wv.Destroy()
200211 }
201212
202- deadline := time.Now().Add(cefShutdownWaitTimeout)
203- for {
204- remaining := e.activeWebViewCount()
205- if remaining == 0 {
206- log.Debug().Msg("cef: all active webviews closed before shutdown")
207- return
208- }
209- if time.Now().After(deadline) {
210- log.Warn().
211- Int("remaining", remaining).
212- Str("timeout", cefShutdownWaitTimeout.String()).
213- Msg("cef: timed out waiting for OnBeforeClose before shutdown")
214- return
215- }
216- time.Sleep(cefShutdownWaitStep)
213+ // Check immediately in case all webviews closed synchronously.
214+ if e.activeWebViewCount() == 0 {
215+ log.Debug().Msg("cef: all active webviews closed before shutdown")
216+ return
217+ }
218+
219+ // Wait for unregisterWebView to signal that all webviews are gone, or timeout.
220+ select {
221+ case <-e.shutdownNotify:
222+ log.Debug().Msg("cef: all active webviews closed before shutdown")
223+ case <-time.After(cefShutdownWaitTimeout):
224+ log.Warn().
225+ Int("remaining", e.activeWebViewCount()).
226+ Str("timeout", cefShutdownWaitTimeout.String()).
227+ Msg("cef: timed out waiting for OnBeforeClose before shutdown")
217228 }
218229 }
219230
@@ -34,6 +34,10 @@ type Engine struct {
34 // activeWebViews tracks all live webviews for CSS broadcast.34 // activeWebViews tracks all live webviews for CSS broadcast.
35 activeWebViews sync.Map // map[port.WebViewID]*WebView35 activeWebViews sync.Map // map[port.WebViewID]*WebView
36 36
37+ // shutdownNotify is signalled when the active webview count reaches 0
38+ // during shutdown, replacing the busy-wait poll in closeActiveWebViews.
39+ shutdownNotify chan struct{}
40+
37 multiThreadedMessageLoop bool41 multiThreadedMessageLoop bool
38 manualPumpInterval int6442 manualPumpInterval int64
39 43
@@ -123,8 +127,15 @@ func (e *Engine) registerWebView(wv *WebView) {
123 }127 }
124 128
125 // unregisterWebView removes a webview from the active tracking map.129 // unregisterWebView removes a webview from the active tracking map.
130+// If the count reaches 0 and a shutdown is in progress, it signals shutdownNotify.
126 func (e *Engine) unregisterWebView(wv *WebView) {131 func (e *Engine) unregisterWebView(wv *WebView) {
127 e.activeWebViews.Delete(wv.id)132 e.activeWebViews.Delete(wv.id)
133+ if e.shutdownNotify != nil && e.activeWebViewCount() == 0 {
134+ select {
135+ case e.shutdownNotify <- struct{}{}:
136+ default:
137+ }
138+ }
128 }139 }
129 140
130 // internalSchemePath is the URI scheme used for internal app resources.141 // internalSchemePath is the URI scheme used for internal app resources.
@@ -158,10 +169,7 @@ func (e *Engine) Close() error {
158 return nil169 return nil
159 }170 }
160 171
161-const (172+const cefShutdownWaitTimeout = 2 * time.Second
162- cefShutdownWaitStep = 10 * time.Millisecond
163- cefShutdownWaitTimeout = 2 * time.Second
164-)
165 173
166 func (e *Engine) activeWebViewCount() int {174 func (e *Engine) activeWebViewCount() int {
167 count := 0175 count := 0
@@ -195,25 +203,28 @@ func (e *Engine) closeActiveWebViews() {
195 Int("count", len(webViews)).203 Int("count", len(webViews)).
196 Msg("cef: closing active webviews before shutdown")204 Msg("cef: closing active webviews before shutdown")
197 205
206+ // Set up the shutdown notification channel before destroying webviews.
207+ e.shutdownNotify = make(chan struct{}, 1)
208+
198 for _, wv := range webViews {209 for _, wv := range webViews {
199 wv.Destroy()210 wv.Destroy()
200 }211 }
201 212
202- deadline := time.Now().Add(cefShutdownWaitTimeout)213+ // Check immediately in case all webviews closed synchronously.
203- for {214+ if e.activeWebViewCount() == 0 {
204- remaining := e.activeWebViewCount()215+ log.Debug().Msg("cef: all active webviews closed before shutdown")
205- if remaining == 0 {216+ return
206- log.Debug().Msg("cef: all active webviews closed before shutdown")217+ }
207- return218+
208- }219+ // Wait for unregisterWebView to signal that all webviews are gone, or timeout.
209- if time.Now().After(deadline) {220+ select {
210- log.Warn().221+ case <-e.shutdownNotify:
211- Int("remaining", remaining).222+ log.Debug().Msg("cef: all active webviews closed before shutdown")
212- Str("timeout", cefShutdownWaitTimeout.String()).223+ case <-time.After(cefShutdownWaitTimeout):
213- Msg("cef: timed out waiting for OnBeforeClose before shutdown")224+ log.Warn().
214- return225+ Int("remaining", e.activeWebViewCount()).
215- }226+ Str("timeout", cefShutdownWaitTimeout.String()).
216- time.Sleep(cefShutdownWaitStep)227+ Msg("cef: timed out waiting for OnBeforeClose before shutdown")
217 }228 }
218 }229 }
219 230
M internal/infrastructure/cef/gl_loader.go +3 -0
@@ -98,6 +98,9 @@ type glLoader struct {
9898
9999 // Close releases the OpenGL library handle.
100100 func (gl *glLoader) Close() error {
101+ if gl == nil {
102+ return nil
103+ }
101104 if gl.handle != 0 {
102105 err := purego.Dlclose(gl.handle)
103106 gl.handle = 0
@@ -98,6 +98,9 @@ type glLoader struct {
98 98
99 // Close releases the OpenGL library handle.99 // Close releases the OpenGL library handle.
100 func (gl *glLoader) Close() error {100 func (gl *glLoader) Close() error {
101+ if gl == nil {
102+ return nil
103+ }
101 if gl.handle != 0 {104 if gl.handle != 0 {
102 err := purego.Dlclose(gl.handle)105 err := purego.Dlclose(gl.handle)
103 gl.handle = 0106 gl.handle = 0
M internal/infrastructure/cef/render_pipeline.go +37 -11
@@ -474,47 +474,73 @@ func (rp *renderPipeline) uploadToPBO(dirtyRects []rect, fullUpload bool, render
474474 }
475475
476476 func (rp *renderPipeline) uploadPopupTexture(renderSeq, paintSeq uint64) bool {
477+ // Copy staging data under lock, then release to avoid holding the mutex
478+ // during GL upload calls which may block on GPU synchronization.
477479 rp.mu.Lock()
478- defer rp.mu.Unlock()
479480
480481 if !rp.popupVisible || rp.popupWidth <= 0 || rp.popupHeight <= 0 || len(rp.popupStaging) == 0 {
482+ rp.mu.Unlock()
481483 return false
482484 }
483485
484486 if !rp.popupNeedsUpload && !rp.popupSizeChanged && rp.popupTexture != 0 {
487+ rp.mu.Unlock()
485488 return true
486489 }
487490
491+ // Snapshot the staging data and dimensions while holding the lock.
492+ pixelsCopy := make([]byte, len(rp.popupStaging))
493+ copy(pixelsCopy, rp.popupStaging)
494+ popupW := rp.popupWidth
495+ popupH := rp.popupHeight
496+ sizeChanged := rp.popupSizeChanged
497+ needsCreate := rp.popupTexture == 0
498+
499+ rp.mu.Unlock()
500+
501+ // Perform GL operations without the lock.
488502 gl := rp.gl
489503
490- if rp.popupTexture == 0 {
491- gl.genTextures(1, &rp.popupTexture)
492- gl.bindTexture(glTexture2D, rp.popupTexture)
504+ if needsCreate {
505+ var tex uint32
506+ gl.genTextures(1, &tex)
507+ gl.bindTexture(glTexture2D, tex)
493508 gl.texParameteri(glTexture2D, glTextureMinFilter, int32(glLinear))
494509 gl.texParameteri(glTexture2D, glTextureMagFilter, int32(glLinear))
495510 gl.texParameteri(glTexture2D, glTextureWrapS, int32(glClampToEdge))
496511 gl.texParameteri(glTexture2D, glTextureWrapT, int32(glClampToEdge))
512+
513+ // Store the texture ID under lock.
514+ rp.mu.Lock()
515+ rp.popupTexture = tex
516+ rp.mu.Unlock()
497517 } else {
498- gl.bindTexture(glTexture2D, rp.popupTexture)
518+ rp.mu.Lock()
519+ tex := rp.popupTexture
520+ rp.mu.Unlock()
521+ gl.bindTexture(glTexture2D, tex)
499522 }
500523
501- pixels := unsafe.Pointer(&rp.popupStaging[0])
502- if rp.popupSizeChanged {
503- gl.texImage2D(glTexture2D, 0, int32(glRGBA), rp.popupWidth, rp.popupHeight, 0, glBGRA, glUnsignedByte, pixels)
524+ pixels := unsafe.Pointer(&pixelsCopy[0])
525+ if sizeChanged {
526+ gl.texImage2D(glTexture2D, 0, int32(glRGBA), popupW, popupH, 0, glBGRA, glUnsignedByte, pixels)
504527 } else {
505- gl.texSubImage2D(glTexture2D, 0, 0, 0, rp.popupWidth, rp.popupHeight, glBGRA, glUnsignedByte, pixels)
528+ gl.texSubImage2D(glTexture2D, 0, 0, 0, popupW, popupH, glBGRA, glUnsignedByte, pixels)
506529 }
507530 gl.bindTexture(glTexture2D, 0)
508531
532+ // Update state under lock.
533+ rp.mu.Lock()
509534 rp.popupNeedsUpload = false
510535 rp.popupSizeChanged = false
536+ rp.mu.Unlock()
511537
512538 if rp.ctx != nil {
513539 logging.FromContext(rp.ctx).Debug().
514540 Uint64("render_seq", renderSeq).
515541 Uint64("paint_seq", paintSeq).
516- Int32("popup_width", rp.popupWidth).
517- Int32("popup_height", rp.popupHeight).
542+ Int32("popup_width", popupW).
543+ Int32("popup_height", popupH).
518544 Msg("cef: popup texture uploaded")
519545 }
520546
@@ -474,47 +474,73 @@ func (rp *renderPipeline) uploadToPBO(dirtyRects []rect, fullUpload bool, render
474 }474 }
475 475
476 func (rp *renderPipeline) uploadPopupTexture(renderSeq, paintSeq uint64) bool {476 func (rp *renderPipeline) uploadPopupTexture(renderSeq, paintSeq uint64) bool {
477+ // Copy staging data under lock, then release to avoid holding the mutex
478+ // during GL upload calls which may block on GPU synchronization.
477 rp.mu.Lock()479 rp.mu.Lock()
478- defer rp.mu.Unlock()
479 480
480 if !rp.popupVisible || rp.popupWidth <= 0 || rp.popupHeight <= 0 || len(rp.popupStaging) == 0 {481 if !rp.popupVisible || rp.popupWidth <= 0 || rp.popupHeight <= 0 || len(rp.popupStaging) == 0 {
482+ rp.mu.Unlock()
481 return false483 return false
482 }484 }
483 485
484 if !rp.popupNeedsUpload && !rp.popupSizeChanged && rp.popupTexture != 0 {486 if !rp.popupNeedsUpload && !rp.popupSizeChanged && rp.popupTexture != 0 {
487+ rp.mu.Unlock()
485 return true488 return true
486 }489 }
487 490
491+ // Snapshot the staging data and dimensions while holding the lock.
492+ pixelsCopy := make([]byte, len(rp.popupStaging))
493+ copy(pixelsCopy, rp.popupStaging)
494+ popupW := rp.popupWidth
495+ popupH := rp.popupHeight
496+ sizeChanged := rp.popupSizeChanged
497+ needsCreate := rp.popupTexture == 0
498+
499+ rp.mu.Unlock()
500+
501+ // Perform GL operations without the lock.
488 gl := rp.gl502 gl := rp.gl
489 503
490- if rp.popupTexture == 0 {504+ if needsCreate {
491- gl.genTextures(1, &rp.popupTexture)505+ var tex uint32
492- gl.bindTexture(glTexture2D, rp.popupTexture)506+ gl.genTextures(1, &tex)
507+ gl.bindTexture(glTexture2D, tex)
493 gl.texParameteri(glTexture2D, glTextureMinFilter, int32(glLinear))508 gl.texParameteri(glTexture2D, glTextureMinFilter, int32(glLinear))
494 gl.texParameteri(glTexture2D, glTextureMagFilter, int32(glLinear))509 gl.texParameteri(glTexture2D, glTextureMagFilter, int32(glLinear))
495 gl.texParameteri(glTexture2D, glTextureWrapS, int32(glClampToEdge))510 gl.texParameteri(glTexture2D, glTextureWrapS, int32(glClampToEdge))
496 gl.texParameteri(glTexture2D, glTextureWrapT, int32(glClampToEdge))511 gl.texParameteri(glTexture2D, glTextureWrapT, int32(glClampToEdge))
512+
513+ // Store the texture ID under lock.
514+ rp.mu.Lock()
515+ rp.popupTexture = tex
516+ rp.mu.Unlock()
497 } else {517 } else {
498- gl.bindTexture(glTexture2D, rp.popupTexture)518+ rp.mu.Lock()
519+ tex := rp.popupTexture
520+ rp.mu.Unlock()
521+ gl.bindTexture(glTexture2D, tex)
499 }522 }
500 523
501- pixels := unsafe.Pointer(&rp.popupStaging[0])524+ pixels := unsafe.Pointer(&pixelsCopy[0])
502- if rp.popupSizeChanged {525+ if sizeChanged {
503- gl.texImage2D(glTexture2D, 0, int32(glRGBA), rp.popupWidth, rp.popupHeight, 0, glBGRA, glUnsignedByte, pixels)526+ gl.texImage2D(glTexture2D, 0, int32(glRGBA), popupW, popupH, 0, glBGRA, glUnsignedByte, pixels)
504 } else {527 } else {
505- gl.texSubImage2D(glTexture2D, 0, 0, 0, rp.popupWidth, rp.popupHeight, glBGRA, glUnsignedByte, pixels)528+ gl.texSubImage2D(glTexture2D, 0, 0, 0, popupW, popupH, glBGRA, glUnsignedByte, pixels)
506 }529 }
507 gl.bindTexture(glTexture2D, 0)530 gl.bindTexture(glTexture2D, 0)
508 531
532+ // Update state under lock.
533+ rp.mu.Lock()
509 rp.popupNeedsUpload = false534 rp.popupNeedsUpload = false
510 rp.popupSizeChanged = false535 rp.popupSizeChanged = false
536+ rp.mu.Unlock()
511 537
512 if rp.ctx != nil {538 if rp.ctx != nil {
513 logging.FromContext(rp.ctx).Debug().539 logging.FromContext(rp.ctx).Debug().
514 Uint64("render_seq", renderSeq).540 Uint64("render_seq", renderSeq).
515 Uint64("paint_seq", paintSeq).541 Uint64("paint_seq", paintSeq).
516- Int32("popup_width", rp.popupWidth).542+ Int32("popup_width", popupW).
517- Int32("popup_height", rp.popupHeight).543+ Int32("popup_height", popupH).
518 Msg("cef: popup texture uploaded")544 Msg("cef: popup texture uploaded")
519 }545 }
520 546
M internal/infrastructure/cef/scheme_handler.go +1 -1
@@ -175,7 +175,7 @@ func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef.
175175 headers["Origin"] = origin
176176 }
177177
178- ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
178+ ctx, cancel := context.WithTimeout(h.ctx, 5*time.Minute)
179179 h.logger.Info().
180180 Str("source_url", logging.TruncateURL(sourceURL, 240)).
181181 Int("forwarded_header_count", len(headers)).
@@ -175,7 +175,7 @@ func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef.
175 headers["Origin"] = origin175 headers["Origin"] = origin
176 }176 }
177 177
178- ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)178+ ctx, cancel := context.WithTimeout(h.ctx, 5*time.Minute)
179 h.logger.Info().179 h.logger.Info().
180 Str("source_url", logging.TruncateURL(sourceURL, 240)).180 Str("source_url", logging.TruncateURL(sourceURL, 240)).
181 Int("forwarded_header_count", len(headers)).181 Int("forwarded_header_count", len(headers)).
M internal/infrastructure/cef/transcoding_handler.go +10 -1
@@ -80,6 +80,9 @@ func (rh *transcodingResourceHandler) GetResponseHeaders(response purecef.Respon
8080 response.SetStatusText("OK")
8181 response.SetMimeType("video/webm")
8282 response.SetHeaderByName("Accept-Ranges", "none", 1)
83+ // CORS wildcard is required because transcoded streams are loaded by <video>
84+ // elements on arbitrary origins (e.g. reddit.com). The streams contain no
85+ // sensitive data — they are re-encoded from publicly-accessible media URLs.
8386 response.SetHeaderByName("Access-Control-Allow-Origin", "*", 1)
8487 response.SetHeaderByName("Cache-Control", "no-store", 1)
8588 // Streaming — unknown length.
@@ -264,7 +267,13 @@ func (h *transcodingRequestHandler) GetResourceHandler(_ purecef.Browser, _ pure
264267 return nil
265268 }
266269
267- ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
270+ parentCtx := context.Background()
271+ if h.ctxf != nil {
272+ if provided := h.ctxf(); provided != nil {
273+ parentCtx = provided
274+ }
275+ }
276+ ctx, cancel := context.WithTimeout(parentCtx, 5*time.Minute)
268277 h.logger().Info().
269278 Str("url", logging.TruncateURL(url, 240)).
270279 Str("source_url", logging.TruncateURL(sourceURL, 240)).
@@ -80,6 +80,9 @@ func (rh *transcodingResourceHandler) GetResponseHeaders(response purecef.Respon
80 response.SetStatusText("OK")80 response.SetStatusText("OK")
81 response.SetMimeType("video/webm")81 response.SetMimeType("video/webm")
82 response.SetHeaderByName("Accept-Ranges", "none", 1)82 response.SetHeaderByName("Accept-Ranges", "none", 1)
83+ // CORS wildcard is required because transcoded streams are loaded by <video>
84+ // elements on arbitrary origins (e.g. reddit.com). The streams contain no
85+ // sensitive data — they are re-encoded from publicly-accessible media URLs.
83 response.SetHeaderByName("Access-Control-Allow-Origin", "*", 1)86 response.SetHeaderByName("Access-Control-Allow-Origin", "*", 1)
84 response.SetHeaderByName("Cache-Control", "no-store", 1)87 response.SetHeaderByName("Cache-Control", "no-store", 1)
85 // Streaming — unknown length.88 // Streaming — unknown length.
@@ -264,7 +267,13 @@ func (h *transcodingRequestHandler) GetResourceHandler(_ purecef.Browser, _ pure
264 return nil267 return nil
265 }268 }
266 269
267- ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)270+ parentCtx := context.Background()
271+ if h.ctxf != nil {
272+ if provided := h.ctxf(); provided != nil {
273+ parentCtx = provided
274+ }
275+ }
276+ ctx, cancel := context.WithTimeout(parentCtx, 5*time.Minute)
268 h.logger().Info().277 h.logger().Info().
269 Str("url", logging.TruncateURL(url, 240)).278 Str("url", logging.TruncateURL(url, 240)).
270 Str("source_url", logging.TruncateURL(sourceURL, 240)).279 Str("source_url", logging.TruncateURL(sourceURL, 240)).
M internal/infrastructure/cef/webview_handlers.go +7 -1
@@ -10,6 +10,12 @@ import (
1010 "github.com/bnema/dumber/internal/logging"
1111 )
1212
13+// Console message markers used by injected JavaScript for log filtering.
14+const (
15+ consoleMarkerVideoDiag = "[VIDEO-DIAG]"
16+ consoleMarkerRedditVideoPatch = "[REDDIT-VIDEO-PATCH]"
17+)
18+
1319 // handlerSet implements all CEF handler interfaces and dispatches events to the
1420 // owning WebView. A single struct is used so that the Client's Get*Handler
1521 // methods can return the same receiver, avoiding extra allocations.
@@ -316,7 +322,7 @@ func (h *handlerSet) OnStatusMessage(_ purecef.Browser, value string) {
316322 }
317323
318324 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]")) {
325+ if h.wv != nil && h.wv.ctx != nil && (strings.Contains(message, consoleMarkerVideoDiag) || strings.Contains(message, consoleMarkerRedditVideoPatch)) {
320326 log := logging.FromContext(h.wv.ctx).With().
321327 Str("component", "cef-console").
322328 Str("source", source).
@@ -10,6 +10,12 @@ import (
10 "github.com/bnema/dumber/internal/logging"10 "github.com/bnema/dumber/internal/logging"
11 )11 )
12 12
13+// Console message markers used by injected JavaScript for log filtering.
14+const (
15+ consoleMarkerVideoDiag = "[VIDEO-DIAG]"
16+ consoleMarkerRedditVideoPatch = "[REDDIT-VIDEO-PATCH]"
17+)
18+
13 // handlerSet implements all CEF handler interfaces and dispatches events to the19 // handlerSet implements all CEF handler interfaces and dispatches events to the
14 // owning WebView. A single struct is used so that the Client's Get*Handler20 // owning WebView. A single struct is used so that the Client's Get*Handler
15 // methods can return the same receiver, avoiding extra allocations.21 // methods can return the same receiver, avoiding extra allocations.
@@ -316,7 +322,7 @@ func (h *handlerSet) OnStatusMessage(_ purecef.Browser, value string) {
316 }322 }
317 323
318 func (h *handlerSet) OnConsoleMessage(_ purecef.Browser, level purecef.LogSeverity, message, source string, line int32) int32 {324 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]")) {325+ if h.wv != nil && h.wv.ctx != nil && (strings.Contains(message, consoleMarkerVideoDiag) || strings.Contains(message, consoleMarkerRedditVideoPatch)) {
320 log := logging.FromContext(h.wv.ctx).With().326 log := logging.FromContext(h.wv.ctx).With().
321 Str("component", "cef-console").327 Str("component", "cef-console").
322 Str("source", source).328 Str("source", source).
M internal/infrastructure/cef/webview_zoom_test.go +3 -4
@@ -2,8 +2,9 @@ package cef
22
33 import (
44 "fmt"
5- "math"
65 "testing"
6+
7+ "github.com/stretchr/testify/assert"
78 )
89
910 func TestZoomConversionsRoundTrip(t *testing.T) {
@@ -12,9 +13,7 @@ func TestZoomConversionsRoundTrip(t *testing.T) {
1213 t.Run(fmt.Sprintf("factor_%.1f", factor), func(t *testing.T) {
1314 level := cefZoomFromFactor(factor)
1415 got := factorFromCEFZoom(level)
15- if math.Abs(got-factor) > 1e-9 {
16- t.Errorf("round-trip mismatch for factor %.6f: level %.6f -> factor %.12f", factor, level, got)
17- }
16+ assert.InDelta(t, factor, got, 1e-9, "round-trip mismatch for factor %.6f: level %.6f -> factor %.12f", factor, level, got)
1817 })
1918 }
2019 }
@@ -2,8 +2,9 @@ package cef
2 2
3 import (3 import (
4 "fmt"4 "fmt"
5- "math"
6 "testing"5 "testing"
6+
7+ "github.com/stretchr/testify/assert"
7 )8 )
8 9
9 func TestZoomConversionsRoundTrip(t *testing.T) {10 func TestZoomConversionsRoundTrip(t *testing.T) {
@@ -12,9 +13,7 @@ func TestZoomConversionsRoundTrip(t *testing.T) {
12 t.Run(fmt.Sprintf("factor_%.1f", factor), func(t *testing.T) {13 t.Run(fmt.Sprintf("factor_%.1f", factor), func(t *testing.T) {
13 level := cefZoomFromFactor(factor)14 level := cefZoomFromFactor(factor)
14 got := factorFromCEFZoom(level)15 got := factorFromCEFZoom(level)
15- if math.Abs(got-factor) > 1e-9 {16+ assert.InDelta(t, factor, got, 1e-9, "round-trip mismatch for factor %.6f: level %.6f -> factor %.12f", factor, level, got)
16- t.Errorf("round-trip mismatch for factor %.6f: level %.6f -> factor %.12f", factor, level, got)
17- }
18 })17 })
19 }18 }
20 }19 }
M internal/infrastructure/config/loader.go +3 -0
@@ -414,6 +414,7 @@ func (m *Manager) setDefaults() {
414414 m.setDebugDefaults(defaults)
415415 m.setAppearanceDefaults(defaults)
416416 m.setEngineDefaults(defaults)
417+ m.setTranscodingDefaults(defaults)
417418 m.setZoomAndScaleDefaults(defaults)
418419 m.setWorkspaceDefaults(defaults)
419420 m.setContentFilteringDefaults(defaults)
@@ -606,7 +607,9 @@ func (m *Manager) setEngineDefaults(defaults *Config) {
606607 m.viper.SetDefault("engine.webkit.network_process_memory_poll_interval_sec", wk.NetworkProcessMemoryPollIntervalSec)
607608 m.viper.SetDefault("engine.webkit.network_process_memory_conservative_threshold", wk.NetworkProcessMemoryConservativeThreshold)
608609 m.viper.SetDefault("engine.webkit.network_process_memory_strict_threshold", wk.NetworkProcessMemoryStrictThreshold)
610+}
609611
612+func (m *Manager) setTranscodingDefaults(defaults *Config) {
610613 tc := defaults.Transcoding
611614 m.viper.SetDefault("transcoding.enabled", tc.Enabled)
612615 m.viper.SetDefault("transcoding.hwaccel", tc.HWAccel)
@@ -414,6 +414,7 @@ func (m *Manager) setDefaults() {
414 m.setDebugDefaults(defaults)414 m.setDebugDefaults(defaults)
415 m.setAppearanceDefaults(defaults)415 m.setAppearanceDefaults(defaults)
416 m.setEngineDefaults(defaults)416 m.setEngineDefaults(defaults)
417+ m.setTranscodingDefaults(defaults)
417 m.setZoomAndScaleDefaults(defaults)418 m.setZoomAndScaleDefaults(defaults)
418 m.setWorkspaceDefaults(defaults)419 m.setWorkspaceDefaults(defaults)
419 m.setContentFilteringDefaults(defaults)420 m.setContentFilteringDefaults(defaults)
@@ -606,7 +607,9 @@ func (m *Manager) setEngineDefaults(defaults *Config) {
606 m.viper.SetDefault("engine.webkit.network_process_memory_poll_interval_sec", wk.NetworkProcessMemoryPollIntervalSec)607 m.viper.SetDefault("engine.webkit.network_process_memory_poll_interval_sec", wk.NetworkProcessMemoryPollIntervalSec)
607 m.viper.SetDefault("engine.webkit.network_process_memory_conservative_threshold", wk.NetworkProcessMemoryConservativeThreshold)608 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)609 m.viper.SetDefault("engine.webkit.network_process_memory_strict_threshold", wk.NetworkProcessMemoryStrictThreshold)
610+}
609 611
612+func (m *Manager) setTranscodingDefaults(defaults *Config) {
610 tc := defaults.Transcoding613 tc := defaults.Transcoding
611 m.viper.SetDefault("transcoding.enabled", tc.Enabled)614 m.viper.SetDefault("transcoding.enabled", tc.Enabled)
612 m.viper.SetDefault("transcoding.hwaccel", tc.HWAccel)615 m.viper.SetDefault("transcoding.hwaccel", tc.HWAccel)
M internal/infrastructure/config/writer_test.go +13 -2
@@ -46,8 +46,19 @@ func TestWriteConfigOrdered(t *testing.T) {
4646 if !strings.Contains(string(content), "[transcoding]") {
4747 t.Fatalf("expected transcoding section in generated config, got:\n%s", string(content))
4848 }
49- if !strings.Contains(string(content), "enabled = true") {
50- t.Fatalf("expected transcoding.enabled default in generated config, got:\n%s", string(content))
49+ // Match "enabled = true" specifically within the [transcoding] section to
50+ // avoid false positives from other sections that may also have an enabled key.
51+ transcodingIdx := strings.Index(string(content), "[transcoding]")
52+ afterTranscoding := string(content)[transcodingIdx:]
53+ nextSection := strings.Index(afterTranscoding[1:], "\n[")
54+ var transcodingBlock string
55+ if nextSection >= 0 {
56+ transcodingBlock = afterTranscoding[:nextSection+1]
57+ } else {
58+ transcodingBlock = afterTranscoding
59+ }
60+ if !strings.Contains(transcodingBlock, "enabled = true") {
61+ t.Fatalf("expected enabled = true in [transcoding] section, got:\n%s", transcodingBlock)
5162 }
5263
5364 t.Logf("Found %d sections, all sorted", len(sections))
@@ -46,8 +46,19 @@ func TestWriteConfigOrdered(t *testing.T) {
46 if !strings.Contains(string(content), "[transcoding]") {46 if !strings.Contains(string(content), "[transcoding]") {
47 t.Fatalf("expected transcoding section in generated config, got:\n%s", string(content))47 t.Fatalf("expected transcoding section in generated config, got:\n%s", string(content))
48 }48 }
49- if !strings.Contains(string(content), "enabled = true") {49+ // Match "enabled = true" specifically within the [transcoding] section to
50- t.Fatalf("expected transcoding.enabled default in generated config, got:\n%s", string(content))50+ // avoid false positives from other sections that may also have an enabled key.
51+ transcodingIdx := strings.Index(string(content), "[transcoding]")
52+ afterTranscoding := string(content)[transcodingIdx:]
53+ nextSection := strings.Index(afterTranscoding[1:], "\n[")
54+ var transcodingBlock string
55+ if nextSection >= 0 {
56+ transcodingBlock = afterTranscoding[:nextSection+1]
57+ } else {
58+ transcodingBlock = afterTranscoding
59+ }
60+ if !strings.Contains(transcodingBlock, "enabled = true") {
61+ t.Fatalf("expected enabled = true in [transcoding] section, got:\n%s", transcodingBlock)
51 }62 }
52 63
53 t.Logf("Found %d sections, all sorted", len(sections))64 t.Logf("Found %d sections, all sorted", len(sections))
M internal/infrastructure/transcoder/audio.go +15 -7
@@ -132,8 +132,13 @@ func newAudioTranscoder(inCodecPar, outFmtCtx unsafe.Pointer, inStreamIdx int) (
132132 ffmpeg.AVOptSet(swr, "in_sample_fmt", ffmpeg.GetSampleFmtName(inSampleFmt), 0)
133133 ffmpeg.AVOptSet(swr, "out_sample_fmt", ffmpeg.GetSampleFmtName(int32(ffmpeg.SampleFormatSampleFmtFltp)), 0)
134134
135- // Channel layout is inherited from the decoder context via av_opt.
136- // SwrContext reads in_chlayout / out_chlayout — we keep them identical.
135+ // Modern FFmpeg (8.x) requires explicit channel layout configuration
136+ // on the SwrContext. Without it, swr_init may succeed but the resampler
137+ // operates with 0 channels and produces silence. Copy the decoder's
138+ // channel layout to both in and out (we keep them identical — same
139+ // number of channels, only sample rate/format changes).
140+ swrSetChLayoutFromCodecCtx(swr, decCtx, offsetSwrInChLayout)
141+ swrSetChLayoutFromCodecCtx(swr, decCtx, offsetSwrOutChLayout)
137142
138143 if ret := ffmpeg.SwrInit(swr); ret < 0 {
139144 ffmpeg.SwrFree(unsafe.Pointer(&swr))
@@ -221,8 +226,11 @@ func (a *audioTranscoder) processPacket(pkt, outFmtCtx unsafe.Pointer) error {
221226 if a.resampler != nil {
222227 encFrame = a.frame
223228
224- // Get the decoded frame's data and linesize pointers.
225- // Frame data[0] is at offset 0, linesize is not needed for swr.
229+ // AVFrame.data[0] is the first field at offset 0, so casting the
230+ // frame pointer to *unsafe.Pointer yields &data[0]. This is the
231+ // standard FFmpeg pattern for passing data pointers to swr_convert.
232+ // purego-ffmpeg does not expose typed accessors for the data array,
233+ // so raw pointer arithmetic is the only option.
226234 inData := (*unsafe.Pointer)(frame)
227235 outData := (*unsafe.Pointer)(a.frame)
228236 inSamples := frameNbSamples(frame)
@@ -291,7 +299,7 @@ func (a *audioTranscoder) processPacket(pkt, outFmtCtx unsafe.Pointer) error {
291299 // flush drains remaining frames from the decoder and encoder.
292300 // The logger parameter is used to report non-fatal resampling errors
293301 // that occur during flush (we continue flushing rather than aborting).
294-func (a *audioTranscoder) flush(outFmtCtx unsafe.Pointer, logger zerolog.Logger) error {
302+func (a *audioTranscoder) flush(outFmtCtx unsafe.Pointer, logger zerolog.Logger) {
295303 // Flush decoder by sending NULL packet.
296304 ffmpeg.CodecSendPacket(a.decoder, nil)
297305
@@ -308,6 +316,8 @@ func (a *audioTranscoder) flush(outFmtCtx unsafe.Pointer, logger zerolog.Logger)
308316 encFrame := frame
309317 if a.resampler != nil {
310318 encFrame = a.frame
319+ // See processPacket for why these casts are safe: AVFrame.data[0]
320+ // is at offset 0, so the frame pointer itself is &data[0].
311321 inData := (*unsafe.Pointer)(frame)
312322 outData := (*unsafe.Pointer)(a.frame)
313323 inSamples := frameNbSamples(frame)
@@ -364,8 +374,6 @@ func (a *audioTranscoder) flush(outFmtCtx unsafe.Pointer, logger zerolog.Logger)
364374 ffmpeg.InterleavedWriteFrame(outFmtCtx, encPkt)
365375 ffmpeg.PacketUnref(encPkt)
366376 }
367-
368- return nil
369377 }
370378
371379 // close frees all FFmpeg resources owned by the audio transcoder.
@@ -132,8 +132,13 @@ func newAudioTranscoder(inCodecPar, outFmtCtx unsafe.Pointer, inStreamIdx int) (
132 ffmpeg.AVOptSet(swr, "in_sample_fmt", ffmpeg.GetSampleFmtName(inSampleFmt), 0)132 ffmpeg.AVOptSet(swr, "in_sample_fmt", ffmpeg.GetSampleFmtName(inSampleFmt), 0)
133 ffmpeg.AVOptSet(swr, "out_sample_fmt", ffmpeg.GetSampleFmtName(int32(ffmpeg.SampleFormatSampleFmtFltp)), 0)133 ffmpeg.AVOptSet(swr, "out_sample_fmt", ffmpeg.GetSampleFmtName(int32(ffmpeg.SampleFormatSampleFmtFltp)), 0)
134 134
135- // Channel layout is inherited from the decoder context via av_opt.135+ // Modern FFmpeg (8.x) requires explicit channel layout configuration
136- // SwrContext reads in_chlayout / out_chlayout — we keep them identical.136+ // on the SwrContext. Without it, swr_init may succeed but the resampler
137+ // operates with 0 channels and produces silence. Copy the decoder's
138+ // channel layout to both in and out (we keep them identical — same
139+ // number of channels, only sample rate/format changes).
140+ swrSetChLayoutFromCodecCtx(swr, decCtx, offsetSwrInChLayout)
141+ swrSetChLayoutFromCodecCtx(swr, decCtx, offsetSwrOutChLayout)
137 142
138 if ret := ffmpeg.SwrInit(swr); ret < 0 {143 if ret := ffmpeg.SwrInit(swr); ret < 0 {
139 ffmpeg.SwrFree(unsafe.Pointer(&swr))144 ffmpeg.SwrFree(unsafe.Pointer(&swr))
@@ -221,8 +226,11 @@ func (a *audioTranscoder) processPacket(pkt, outFmtCtx unsafe.Pointer) error {
221 if a.resampler != nil {226 if a.resampler != nil {
222 encFrame = a.frame227 encFrame = a.frame
223 228
224- // Get the decoded frame's data and linesize pointers.229+ // AVFrame.data[0] is the first field at offset 0, so casting the
225- // Frame data[0] is at offset 0, linesize is not needed for swr.230+ // frame pointer to *unsafe.Pointer yields &data[0]. This is the
231+ // standard FFmpeg pattern for passing data pointers to swr_convert.
232+ // purego-ffmpeg does not expose typed accessors for the data array,
233+ // so raw pointer arithmetic is the only option.
226 inData := (*unsafe.Pointer)(frame)234 inData := (*unsafe.Pointer)(frame)
227 outData := (*unsafe.Pointer)(a.frame)235 outData := (*unsafe.Pointer)(a.frame)
228 inSamples := frameNbSamples(frame)236 inSamples := frameNbSamples(frame)
@@ -291,7 +299,7 @@ func (a *audioTranscoder) processPacket(pkt, outFmtCtx unsafe.Pointer) error {
291 // flush drains remaining frames from the decoder and encoder.299 // flush drains remaining frames from the decoder and encoder.
292 // The logger parameter is used to report non-fatal resampling errors300 // The logger parameter is used to report non-fatal resampling errors
293 // that occur during flush (we continue flushing rather than aborting).301 // that occur during flush (we continue flushing rather than aborting).
294-func (a *audioTranscoder) flush(outFmtCtx unsafe.Pointer, logger zerolog.Logger) error {302+func (a *audioTranscoder) flush(outFmtCtx unsafe.Pointer, logger zerolog.Logger) {
295 // Flush decoder by sending NULL packet.303 // Flush decoder by sending NULL packet.
296 ffmpeg.CodecSendPacket(a.decoder, nil)304 ffmpeg.CodecSendPacket(a.decoder, nil)
297 305
@@ -308,6 +316,8 @@ func (a *audioTranscoder) flush(outFmtCtx unsafe.Pointer, logger zerolog.Logger)
308 encFrame := frame316 encFrame := frame
309 if a.resampler != nil {317 if a.resampler != nil {
310 encFrame = a.frame318 encFrame = a.frame
319+ // See processPacket for why these casts are safe: AVFrame.data[0]
320+ // is at offset 0, so the frame pointer itself is &data[0].
311 inData := (*unsafe.Pointer)(frame)321 inData := (*unsafe.Pointer)(frame)
312 outData := (*unsafe.Pointer)(a.frame)322 outData := (*unsafe.Pointer)(a.frame)
313 inSamples := frameNbSamples(frame)323 inSamples := frameNbSamples(frame)
@@ -364,8 +374,6 @@ func (a *audioTranscoder) flush(outFmtCtx unsafe.Pointer, logger zerolog.Logger)
364 ffmpeg.InterleavedWriteFrame(outFmtCtx, encPkt)374 ffmpeg.InterleavedWriteFrame(outFmtCtx, encPkt)
365 ffmpeg.PacketUnref(encPkt)375 ffmpeg.PacketUnref(encPkt)
366 }376 }
367-
368- return nil
369 }377 }
370 378
371 // close frees all FFmpeg resources owned by the audio transcoder.379 // close frees all FFmpeg resources owned by the audio transcoder.
M internal/infrastructure/transcoder/detect_test.go +84 -13
@@ -163,19 +163,90 @@ func TestIsEagerTranscodeURL(t *testing.T) {
163163 }
164164
165165 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)
166+ tests := []struct {
167+ name string
168+ rawURL string
169+ wantSrc string
170+ wantRef string
171+ wantOrig string
172+ wantOK bool
173+ }{
174+ {
175+ name: "valid with all params",
176+ 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",
177+ wantSrc: "https://v.redd.it/abc/HLSPlaylist.m3u8",
178+ wantRef: "https://www.reddit.com/r/OpenAI",
179+ wantOrig: "https://www.reddit.com",
180+ wantOK: true,
181+ },
182+ {
183+ name: "valid without referer and origin",
184+ rawURL: "https://example.com/__dumber__/transcode.webm?src=https%3A%2F%2Fcdn.example.com%2Fvideo.mp4",
185+ wantSrc: "https://cdn.example.com/video.mp4",
186+ wantRef: "",
187+ wantOrig: "",
188+ wantOK: true,
189+ },
190+ {
191+ name: "empty URL",
192+ rawURL: "",
193+ wantOK: false,
194+ },
195+ {
196+ name: "missing src parameter",
197+ rawURL: "https://example.com/__dumber__/transcode.webm?referer=https%3A%2F%2Fexample.com",
198+ wantOK: false,
199+ },
200+ {
201+ name: "wrong path",
202+ rawURL: "https://example.com/other/path?src=https%3A%2F%2Fcdn.example.com%2Fvideo.mp4",
203+ wantOK: false,
204+ },
205+ {
206+ name: "src with no host",
207+ rawURL: "https://example.com/__dumber__/transcode.webm?src=not-a-url",
208+ wantOK: false,
209+ },
210+ {
211+ name: "src with non-http scheme",
212+ rawURL: "https://example.com/__dumber__/transcode.webm?src=ftp%3A%2F%2Fcdn.example.com%2Fvideo.mp4",
213+ wantOK: false,
214+ },
215+ {
216+ name: "src is empty string",
217+ rawURL: "https://example.com/__dumber__/transcode.webm?src=",
218+ wantOK: false,
219+ },
220+ {
221+ name: "src is whitespace",
222+ rawURL: "https://example.com/__dumber__/transcode.webm?src=%20%20",
223+ wantOK: false,
224+ },
225+ {
226+ name: "unparseable URL",
227+ rawURL: "://invalid",
228+ wantOK: false,
229+ },
177230 }
178- if origin != "https://www.reddit.com" {
179- t.Fatalf("origin = %q", origin)
231+
232+ for _, tt := range tests {
233+ t.Run(tt.name, func(t *testing.T) {
234+ src, ref, orig, ok := ParseSyntheticTranscodeURL(tt.rawURL)
235+ if ok != tt.wantOK {
236+ t.Fatalf("ParseSyntheticTranscodeURL(%q) ok = %v, want %v", tt.rawURL, ok, tt.wantOK)
237+ }
238+ if !tt.wantOK {
239+ return
240+ }
241+ if src != tt.wantSrc {
242+ t.Errorf("sourceURL = %q, want %q", src, tt.wantSrc)
243+ }
244+ if ref != tt.wantRef {
245+ t.Errorf("referer = %q, want %q", ref, tt.wantRef)
246+ }
247+ if orig != tt.wantOrig {
248+ t.Errorf("origin = %q, want %q", orig, tt.wantOrig)
249+ }
250+ })
180251 }
181252 }
@@ -163,19 +163,90 @@ func TestIsEagerTranscodeURL(t *testing.T) {
163 }163 }
164 164
165 func TestParseSyntheticTranscodeURL(t *testing.T) {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"166+ tests := []struct {
167-167+ name string
168- sourceURL, referer, origin, ok := ParseSyntheticTranscodeURL(rawURL)168+ rawURL string
169- if !ok {169+ wantSrc string
170- t.Fatalf("ParseSyntheticTranscodeURL(%q) = !ok, want ok", rawURL)170+ wantRef string
171- }171+ wantOrig string
172- if sourceURL != "https://v.redd.it/abc/HLSPlaylist.m3u8" {172+ wantOK bool
173- t.Fatalf("sourceURL = %q", sourceURL)173+ }{
174- }174+ {
175- if referer != "https://www.reddit.com/r/OpenAI" {175+ name: "valid with all params",
176- t.Fatalf("referer = %q", referer)176+ 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",
177+ wantSrc: "https://v.redd.it/abc/HLSPlaylist.m3u8",
178+ wantRef: "https://www.reddit.com/r/OpenAI",
179+ wantOrig: "https://www.reddit.com",
180+ wantOK: true,
181+ },
182+ {
183+ name: "valid without referer and origin",
184+ rawURL: "https://example.com/__dumber__/transcode.webm?src=https%3A%2F%2Fcdn.example.com%2Fvideo.mp4",
185+ wantSrc: "https://cdn.example.com/video.mp4",
186+ wantRef: "",
187+ wantOrig: "",
188+ wantOK: true,
189+ },
190+ {
191+ name: "empty URL",
192+ rawURL: "",
193+ wantOK: false,
194+ },
195+ {
196+ name: "missing src parameter",
197+ rawURL: "https://example.com/__dumber__/transcode.webm?referer=https%3A%2F%2Fexample.com",
198+ wantOK: false,
199+ },
200+ {
201+ name: "wrong path",
202+ rawURL: "https://example.com/other/path?src=https%3A%2F%2Fcdn.example.com%2Fvideo.mp4",
203+ wantOK: false,
204+ },
205+ {
206+ name: "src with no host",
207+ rawURL: "https://example.com/__dumber__/transcode.webm?src=not-a-url",
208+ wantOK: false,
209+ },
210+ {
211+ name: "src with non-http scheme",
212+ rawURL: "https://example.com/__dumber__/transcode.webm?src=ftp%3A%2F%2Fcdn.example.com%2Fvideo.mp4",
213+ wantOK: false,
214+ },
215+ {
216+ name: "src is empty string",
217+ rawURL: "https://example.com/__dumber__/transcode.webm?src=",
218+ wantOK: false,
219+ },
220+ {
221+ name: "src is whitespace",
222+ rawURL: "https://example.com/__dumber__/transcode.webm?src=%20%20",
223+ wantOK: false,
224+ },
225+ {
226+ name: "unparseable URL",
227+ rawURL: "://invalid",
228+ wantOK: false,
229+ },
177 }230 }
178- if origin != "https://www.reddit.com" {231+
179- t.Fatalf("origin = %q", origin)232+ for _, tt := range tests {
233+ t.Run(tt.name, func(t *testing.T) {
234+ src, ref, orig, ok := ParseSyntheticTranscodeURL(tt.rawURL)
235+ if ok != tt.wantOK {
236+ t.Fatalf("ParseSyntheticTranscodeURL(%q) ok = %v, want %v", tt.rawURL, ok, tt.wantOK)
237+ }
238+ if !tt.wantOK {
239+ return
240+ }
241+ if src != tt.wantSrc {
242+ t.Errorf("sourceURL = %q, want %q", src, tt.wantSrc)
243+ }
244+ if ref != tt.wantRef {
245+ t.Errorf("referer = %q, want %q", ref, tt.wantRef)
246+ }
247+ if orig != tt.wantOrig {
248+ t.Errorf("origin = %q, want %q", orig, tt.wantOrig)
249+ }
250+ })
180 }251 }
181 }252 }
M internal/infrastructure/transcoder/ffmpeg_offsets.go +34 -7
@@ -1,13 +1,20 @@
11 package transcoder
22
33 // Offset-based accessors for FFmpeg struct fields not yet exposed
4-// by purego-ffmpeg. These offsets are for FFmpeg 7.x (libavcodec 62,
5-// libavformat 62). Verified against the same headers used by purego-ffmpeg.
4+// by purego-ffmpeg.
5+//
6+// Validated against:
7+// FFmpeg 8.1 (n8.1)
8+// libavcodec 62.28.100
9+// libavformat 62.12.100
10+// libavutil 60.26.100
11+//
12+// All offsets verified via offsetof() on the installed headers.
613
714 import "unsafe"
815
916 // ---------------------------------------------------------------------------
10-// AVPacket field offsets (FFmpeg 7.x)
17+// AVPacket field offsets (FFmpeg 8.x, libavcodec 62)
1118 // Layout: AVBufferRef* buf (0), int64 pts (8), int64 dts (16),
1219 // uint8* data (24), int size (32), int stream_index (36), int flags (40)
1320 // ---------------------------------------------------------------------------
@@ -40,7 +47,6 @@ func pktSetStreamIndex(pkt unsafe.Pointer, idx int32) {
4047 // sample_rate at offset 180
4148 // hw_frames_ctx at offset 328 (AVBufferRef*)
4249 // ch_layout at offset 384 (AVChannelLayout, 24 bytes)
43-// hw_frames_ctx at offset 328 (AVBufferRef*)
4450 // ---------------------------------------------------------------------------
4551
4652 const (
@@ -106,8 +112,7 @@ func frameSetSampleRate(f unsafe.Pointer, v int32) {
106112 }
107113
108114 // ---------------------------------------------------------------------------
109-// AVCodecContext additional offsets
110-// Verified against the installed libavcodec headers via offsetof().
115+// AVCodecContext additional offsets (FFmpeg 8.x, libavcodec 62)
111116 // ---------------------------------------------------------------------------
112117
113118 const (
@@ -150,7 +155,29 @@ func codecCtxCopyChLayout(dst, src unsafe.Pointer) {
150155 }
151156
152157 // ---------------------------------------------------------------------------
153-// AVCodecParameters additional offsets
158+// SwrContext channel layout offsets (FFmpeg 8.x, libswresample)
159+// SwrContext is opaque; offsets verified by writing known AVChannelLayout
160+// values via av_opt_set_chlayout and scanning the resulting memory.
161+// ---------------------------------------------------------------------------
162+
163+const (
164+ offsetSwrInChLayout = 192 // AVChannelLayout (24 bytes)
165+ offsetSwrOutChLayout = 216 // AVChannelLayout (24 bytes)
166+)
167+
168+// swrCopyChLayoutFromCodecCtx copies an AVChannelLayout (24 bytes) from
169+// an AVCodecContext's ch_layout field into the SwrContext's in/out ch_layout.
170+func swrSetChLayoutFromCodecCtx(swr, codecCtx unsafe.Pointer, swrOffset int) {
171+ dst := unsafe.Add(swr, swrOffset)
172+ src := unsafe.Add(codecCtx, offsetCodecCtxChLayout)
173+ copy(
174+ unsafe.Slice((*byte)(dst), 24),
175+ unsafe.Slice((*byte)(src), 24),
176+ )
177+}
178+
179+// ---------------------------------------------------------------------------
180+// AVCodecParameters additional offsets (FFmpeg 8.x, libavcodec 62)
154181 // ---------------------------------------------------------------------------
155182
156183 const (
@@ -1,13 +1,20 @@
1 package transcoder1 package transcoder
2 2
3 // Offset-based accessors for FFmpeg struct fields not yet exposed3 // Offset-based accessors for FFmpeg struct fields not yet exposed
4-// by purego-ffmpeg. These offsets are for FFmpeg 7.x (libavcodec 62,4+// by purego-ffmpeg.
5-// libavformat 62). Verified against the same headers used by purego-ffmpeg.5+//
6+// Validated against:
7+// FFmpeg 8.1 (n8.1)
8+// libavcodec 62.28.100
9+// libavformat 62.12.100
10+// libavutil 60.26.100
11+//
12+// All offsets verified via offsetof() on the installed headers.
6 13
7 import "unsafe"14 import "unsafe"
8 15
9 // ---------------------------------------------------------------------------16 // ---------------------------------------------------------------------------
10-// AVPacket field offsets (FFmpeg 7.x)17+// AVPacket field offsets (FFmpeg 8.x, libavcodec 62)
11 // Layout: AVBufferRef* buf (0), int64 pts (8), int64 dts (16),18 // Layout: AVBufferRef* buf (0), int64 pts (8), int64 dts (16),
12 // uint8* data (24), int size (32), int stream_index (36), int flags (40)19 // uint8* data (24), int size (32), int stream_index (36), int flags (40)
13 // ---------------------------------------------------------------------------20 // ---------------------------------------------------------------------------
@@ -40,7 +47,6 @@ func pktSetStreamIndex(pkt unsafe.Pointer, idx int32) {
40 // sample_rate at offset 18047 // sample_rate at offset 180
41 // hw_frames_ctx at offset 328 (AVBufferRef*)48 // hw_frames_ctx at offset 328 (AVBufferRef*)
42 // ch_layout at offset 384 (AVChannelLayout, 24 bytes)49 // ch_layout at offset 384 (AVChannelLayout, 24 bytes)
43-// hw_frames_ctx at offset 328 (AVBufferRef*)
44 // ---------------------------------------------------------------------------50 // ---------------------------------------------------------------------------
45 51
46 const (52 const (
@@ -106,8 +112,7 @@ func frameSetSampleRate(f unsafe.Pointer, v int32) {
106 }112 }
107 113
108 // ---------------------------------------------------------------------------114 // ---------------------------------------------------------------------------
109-// AVCodecContext additional offsets115+// AVCodecContext additional offsets (FFmpeg 8.x, libavcodec 62)
110-// Verified against the installed libavcodec headers via offsetof().
111 // ---------------------------------------------------------------------------116 // ---------------------------------------------------------------------------
112 117
113 const (118 const (
@@ -150,7 +155,29 @@ func codecCtxCopyChLayout(dst, src unsafe.Pointer) {
150 }155 }
151 156
152 // ---------------------------------------------------------------------------157 // ---------------------------------------------------------------------------
153-// AVCodecParameters additional offsets158+// SwrContext channel layout offsets (FFmpeg 8.x, libswresample)
159+// SwrContext is opaque; offsets verified by writing known AVChannelLayout
160+// values via av_opt_set_chlayout and scanning the resulting memory.
161+// ---------------------------------------------------------------------------
162+
163+const (
164+ offsetSwrInChLayout = 192 // AVChannelLayout (24 bytes)
165+ offsetSwrOutChLayout = 216 // AVChannelLayout (24 bytes)
166+)
167+
168+// swrCopyChLayoutFromCodecCtx copies an AVChannelLayout (24 bytes) from
169+// an AVCodecContext's ch_layout field into the SwrContext's in/out ch_layout.
170+func swrSetChLayoutFromCodecCtx(swr, codecCtx unsafe.Pointer, swrOffset int) {
171+ dst := unsafe.Add(swr, swrOffset)
172+ src := unsafe.Add(codecCtx, offsetCodecCtxChLayout)
173+ copy(
174+ unsafe.Slice((*byte)(dst), 24),
175+ unsafe.Slice((*byte)(src), 24),
176+ )
177+}
178+
179+// ---------------------------------------------------------------------------
180+// AVCodecParameters additional offsets (FFmpeg 8.x, libavcodec 62)
154 // ---------------------------------------------------------------------------181 // ---------------------------------------------------------------------------
155 182
156 const (183 const (
M internal/infrastructure/transcoder/pipeline.go +31 -13
@@ -191,9 +191,14 @@ func (p *pipeline) doRun(ctx context.Context) error {
191191 audioIdx = -1
192192 }
193193 }
194- if audioTx != nil {
195- defer audioTx.close()
196- }
194+ // Use a closure so the defer sees the current value of audioTx at
195+ // cleanup time. This allows us to close and nil-out audioTx mid-loop
196+ // (e.g., on audio processing error) without double-closing.
197+ defer func() {
198+ if audioTx != nil {
199+ audioTx.close()
200+ }
201+ }()
197202
198203 // --- Step 12: Write header ---
199204 if ret := ffmpeg.FormatWriteHeader(outFmtCtx, nil); ret < 0 {
@@ -244,8 +249,16 @@ func (p *pipeline) doRun(ctx context.Context) error {
244249
245250 case audioTx != nil && streamIdx == audioIdx:
246251 if err := audioTx.processPacket(pkt, outFmtCtx); err != nil {
247- ffmpeg.PacketUnref(pkt)
248- return fmt.Errorf("audio transcode: %w", err)
252+ // Audio errors are non-fatal: log warning, disable audio,
253+ // and continue with video-only output. This matches the
254+ // non-fatal handling of audio init failure above.
255+ p.logger.Warn().
256+ Str("session_id", p.sessionID).
257+ Err(err).
258+ Msg("audio processing failed, continuing with video only")
259+ audioTx.close()
260+ audioTx = nil
261+ audioIdx = -1
249262 }
250263 }
251264
@@ -383,15 +396,20 @@ func (p *pipeline) openCustomIOInputContext(ctx context.Context) (unsafe.Pointer
383396 return -1
384397 }
385398 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
399+ // Retry up to 3 times for the edge case where body.Read returns
400+ // n=0, err=nil (allowed by the io.Reader contract but unusual).
401+ for tries := 0; tries < 3; tries++ {
402+ n, readErr := body.Read(goBuf)
403+ if n > 0 {
404+ return int32(n)
405+ }
406+ if readErr != nil {
407+ // Return AVERROR_EOF for EOF, generic error otherwise.
408+ return averrorEOF
409+ }
410+ // n == 0 && err == nil: retry
393411 }
394- return -1
412+ return averrorEOF
395413 }
396414 p.readCb = purego.NewCallback(readFn)
397415
@@ -191,9 +191,14 @@ func (p *pipeline) doRun(ctx context.Context) error {
191 audioIdx = -1191 audioIdx = -1
192 }192 }
193 }193 }
194- if audioTx != nil {194+ // Use a closure so the defer sees the current value of audioTx at
195- defer audioTx.close()195+ // cleanup time. This allows us to close and nil-out audioTx mid-loop
196- }196+ // (e.g., on audio processing error) without double-closing.
197+ defer func() {
198+ if audioTx != nil {
199+ audioTx.close()
200+ }
201+ }()
197 202
198 // --- Step 12: Write header ---203 // --- Step 12: Write header ---
199 if ret := ffmpeg.FormatWriteHeader(outFmtCtx, nil); ret < 0 {204 if ret := ffmpeg.FormatWriteHeader(outFmtCtx, nil); ret < 0 {
@@ -244,8 +249,16 @@ func (p *pipeline) doRun(ctx context.Context) error {
244 249
245 case audioTx != nil && streamIdx == audioIdx:250 case audioTx != nil && streamIdx == audioIdx:
246 if err := audioTx.processPacket(pkt, outFmtCtx); err != nil {251 if err := audioTx.processPacket(pkt, outFmtCtx); err != nil {
247- ffmpeg.PacketUnref(pkt)252+ // Audio errors are non-fatal: log warning, disable audio,
248- return fmt.Errorf("audio transcode: %w", err)253+ // and continue with video-only output. This matches the
254+ // non-fatal handling of audio init failure above.
255+ p.logger.Warn().
256+ Str("session_id", p.sessionID).
257+ Err(err).
258+ Msg("audio processing failed, continuing with video only")
259+ audioTx.close()
260+ audioTx = nil
261+ audioIdx = -1
249 }262 }
250 }263 }
251 264
@@ -383,15 +396,20 @@ func (p *pipeline) openCustomIOInputContext(ctx context.Context) (unsafe.Pointer
383 return -1396 return -1
384 }397 }
385 goBuf := unsafe.Slice((*byte)(buf), int(bufSize))398 goBuf := unsafe.Slice((*byte)(buf), int(bufSize))
386- n, readErr := body.Read(goBuf)399+ // Retry up to 3 times for the edge case where body.Read returns
387- if n > 0 {400+ // n=0, err=nil (allowed by the io.Reader contract but unusual).
388- return int32(n)401+ for tries := 0; tries < 3; tries++ {
389- }402+ n, readErr := body.Read(goBuf)
390- if readErr != nil {403+ if n > 0 {
391- // Return AVERROR_EOF for EOF, generic error otherwise.404+ return int32(n)
392- return averrorEOF405+ }
406+ if readErr != nil {
407+ // Return AVERROR_EOF for EOF, generic error otherwise.
408+ return averrorEOF
409+ }
410+ // n == 0 && err == nil: retry
393 }411 }
394- return -1412+ return averrorEOF
395 }413 }
396 p.readCb = purego.NewCallback(readFn)414 p.readCb = purego.NewCallback(readFn)
397 415
M internal/infrastructure/transcoder/pool.go +16 -4
@@ -88,13 +88,25 @@ func (p *sessionPool) count() int {
8888 return len(p.sessions)
8989 }
9090
91-// closeAll cancels and closes every active session.
91+// closeAll cancels and closes every active session. It collects sessions
92+// under the lock, clears the map, then releases the lock before calling
93+// Close on each session to avoid holding the lock during potentially
94+// blocking I/O.
9295 func (p *sessionPool) closeAll() {
9396 p.mu.Lock()
94- defer p.mu.Unlock()
95- for id, s := range p.sessions {
97+ snapshot := make([]*session, 0, len(p.sessions))
98+ for _, s := range p.sessions {
99+ snapshot = append(snapshot, s)
100+ }
101+ // Clear the map while holding the lock.
102+ for id := range p.sessions {
103+ delete(p.sessions, id)
104+ }
105+ p.mu.Unlock()
106+
107+ // Close sessions outside the lock.
108+ for _, s := range snapshot {
96109 s.cancel()
97110 s.pr.Close()
98- delete(p.sessions, id)
99111 }
100112 }
@@ -88,13 +88,25 @@ func (p *sessionPool) count() int {
88 return len(p.sessions)88 return len(p.sessions)
89 }89 }
90 90
91-// closeAll cancels and closes every active session.91+// closeAll cancels and closes every active session. It collects sessions
92+// under the lock, clears the map, then releases the lock before calling
93+// Close on each session to avoid holding the lock during potentially
94+// blocking I/O.
92 func (p *sessionPool) closeAll() {95 func (p *sessionPool) closeAll() {
93 p.mu.Lock()96 p.mu.Lock()
94- defer p.mu.Unlock()97+ snapshot := make([]*session, 0, len(p.sessions))
95- for id, s := range p.sessions {98+ for _, s := range p.sessions {
99+ snapshot = append(snapshot, s)
100+ }
101+ // Clear the map while holding the lock.
102+ for id := range p.sessions {
103+ delete(p.sessions, id)
104+ }
105+ p.mu.Unlock()
106+
107+ // Close sessions outside the lock.
108+ for _, s := range snapshot {
96 s.cancel()109 s.cancel()
97 s.pr.Close()110 s.pr.Close()
98- delete(p.sessions, id)
99 }111 }
100 }112 }
M internal/infrastructure/transcoder/transcoder.go +7 -6
@@ -36,13 +36,14 @@ func New(cfg config.TranscodingConfig, logger *zerolog.Logger) *Transcoder {
3636 maxConc = 2
3737 }
3838
39- quality := cfg.Quality
40- if quality == "" {
41- quality = "medium"
39+ // Work on a local copy to avoid mutating a potentially shared config.
40+ localCfg := cfg
41+
42+ if localCfg.Quality == "" {
43+ localCfg.Quality = "medium"
4244 }
43- cfg.Quality = quality
4445
45- hwaccel := cfg.HWAccel
46+ hwaccel := localCfg.HWAccel
4647 if hwaccel == "" {
4748 hwaccel = "auto"
4849 }
@@ -62,7 +63,7 @@ func New(cfg config.TranscodingConfig, logger *zerolog.Logger) *Transcoder {
6263 }
6364
6465 return &Transcoder{
65- cfg: cfg,
66+ cfg: localCfg,
6667 hwCaps: hwCaps,
6768 pool: newSessionPool(maxConc),
6869 logger: l,
@@ -36,13 +36,14 @@ func New(cfg config.TranscodingConfig, logger *zerolog.Logger) *Transcoder {
36 maxConc = 236 maxConc = 2
37 }37 }
38 38
39- quality := cfg.Quality39+ // Work on a local copy to avoid mutating a potentially shared config.
40- if quality == "" {40+ localCfg := cfg
41- quality = "medium"41+
42+ if localCfg.Quality == "" {
43+ localCfg.Quality = "medium"
42 }44 }
43- cfg.Quality = quality
44 45
45- hwaccel := cfg.HWAccel46+ hwaccel := localCfg.HWAccel
46 if hwaccel == "" {47 if hwaccel == "" {
47 hwaccel = "auto"48 hwaccel = "auto"
48 }49 }
@@ -62,7 +63,7 @@ func New(cfg config.TranscodingConfig, logger *zerolog.Logger) *Transcoder {
62 }63 }
63 64
64 return &Transcoder{65 return &Transcoder{
65- cfg: cfg,66+ cfg: localCfg,
66 hwCaps: hwCaps,67 hwCaps: hwCaps,
67 pool: newSessionPool(maxConc),68 pool: newSessionPool(maxConc),
68 logger: l,69 logger: l,
M internal/ui/adapter/favicon.go +8 -8
@@ -113,7 +113,7 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac
113113
114114 // Handle internal dumb:// scheme URLs - use app logo
115115 if a.isInternalURL != nil && a.isInternalURL(pageURL) {
116- texture := a.textureFromBytesOnGTK(a.getLogoBytes())
116+ texture := a.textureFromBytesOnGTK(ctx, a.getLogoBytes())
117117 if texture != nil {
118118 a.setTexture(a.internalDomain, texture)
119119 }
@@ -142,7 +142,7 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac
142142 Str("domain", domain).
143143 Int("bytes", len(data)).
144144 Msg("favicon: service cache hit")
145- texture := a.textureFromBytesOnGTK(data)
145+ texture := a.textureFromBytesOnGTK(ctx, data)
146146 if texture != nil {
147147 a.setTexture(domain, texture)
148148 // Ensure sized PNG exists for CLI tools (async, idempotent)
@@ -188,7 +188,7 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac
188188 Str("domain", domain).
189189 Int("bytes", len(data)).
190190 Msg("favicon: fetchViaService fetched data")
191- texture := a.textureFromBytesOnGTK(data)
191+ texture := a.textureFromBytesOnGTK(ctx, data)
192192 if texture != nil {
193193 a.setTexture(domain, texture)
194194 }
@@ -364,7 +364,7 @@ func (a *FaviconAdapter) PreloadFromCache(ctx context.Context, pageURL string) *
364364 Str("domain", domain).
365365 Int("bytes", len(data)).
366366 Msg("favicon: PreloadFromCache service hit")
367- texture := a.textureFromBytesOnGTK(data)
367+ texture := a.textureFromBytesOnGTK(ctx, data)
368368 if texture != nil {
369369 a.setTexture(domain, texture)
370370 return texture
@@ -483,7 +483,7 @@ func (a *FaviconAdapter) logWarningDedup(
483483
484484 // getOrCreateLogoTexture returns the app logo texture, creating it if needed.
485485 // The texture is cached under the internalDomain key.
486-func (a *FaviconAdapter) getOrCreateLogoTexture() *gdk.Texture {
486+func (a *FaviconAdapter) getOrCreateLogoTexture(ctx context.Context) *gdk.Texture {
487487 if a.internalDomain == "" || a.getLogoBytes == nil {
488488 return nil
489489 }
@@ -495,18 +495,18 @@ func (a *FaviconAdapter) getOrCreateLogoTexture() *gdk.Texture {
495495
496496 // Create texture from logo bytes
497497 logoBytes := a.getLogoBytes()
498- texture := a.textureFromBytesOnGTK(logoBytes)
498+ texture := a.textureFromBytesOnGTK(ctx, logoBytes)
499499 if texture != nil {
500500 a.setTexture(a.internalDomain, texture)
501501 }
502502 return texture
503503 }
504504
505-func (a *FaviconAdapter) textureFromBytesOnGTK(data []byte) *gdk.Texture {
505+func (a *FaviconAdapter) textureFromBytesOnGTK(ctx context.Context, data []byte) *gdk.Texture {
506506 if len(data) == 0 {
507507 return nil
508508 }
509- log := logging.FromContext(context.Background())
509+ log := logging.FromContext(ctx)
510510 log.Debug().
511511 Int("bytes", len(data)).
512512 Msg("favicon: textureFromBytesOnGTK begin")
@@ -113,7 +113,7 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac
113 113
114 // Handle internal dumb:// scheme URLs - use app logo114 // Handle internal dumb:// scheme URLs - use app logo
115 if a.isInternalURL != nil && a.isInternalURL(pageURL) {115 if a.isInternalURL != nil && a.isInternalURL(pageURL) {
116- texture := a.textureFromBytesOnGTK(a.getLogoBytes())116+ texture := a.textureFromBytesOnGTK(ctx, a.getLogoBytes())
117 if texture != nil {117 if texture != nil {
118 a.setTexture(a.internalDomain, texture)118 a.setTexture(a.internalDomain, texture)
119 }119 }
@@ -142,7 +142,7 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac
142 Str("domain", domain).142 Str("domain", domain).
143 Int("bytes", len(data)).143 Int("bytes", len(data)).
144 Msg("favicon: service cache hit")144 Msg("favicon: service cache hit")
145- texture := a.textureFromBytesOnGTK(data)145+ texture := a.textureFromBytesOnGTK(ctx, data)
146 if texture != nil {146 if texture != nil {
147 a.setTexture(domain, texture)147 a.setTexture(domain, texture)
148 // Ensure sized PNG exists for CLI tools (async, idempotent)148 // Ensure sized PNG exists for CLI tools (async, idempotent)
@@ -188,7 +188,7 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac
188 Str("domain", domain).188 Str("domain", domain).
189 Int("bytes", len(data)).189 Int("bytes", len(data)).
190 Msg("favicon: fetchViaService fetched data")190 Msg("favicon: fetchViaService fetched data")
191- texture := a.textureFromBytesOnGTK(data)191+ texture := a.textureFromBytesOnGTK(ctx, data)
192 if texture != nil {192 if texture != nil {
193 a.setTexture(domain, texture)193 a.setTexture(domain, texture)
194 }194 }
@@ -364,7 +364,7 @@ func (a *FaviconAdapter) PreloadFromCache(ctx context.Context, pageURL string) *
364 Str("domain", domain).364 Str("domain", domain).
365 Int("bytes", len(data)).365 Int("bytes", len(data)).
366 Msg("favicon: PreloadFromCache service hit")366 Msg("favicon: PreloadFromCache service hit")
367- texture := a.textureFromBytesOnGTK(data)367+ texture := a.textureFromBytesOnGTK(ctx, data)
368 if texture != nil {368 if texture != nil {
369 a.setTexture(domain, texture)369 a.setTexture(domain, texture)
370 return texture370 return texture
@@ -483,7 +483,7 @@ func (a *FaviconAdapter) logWarningDedup(
483 483
484 // getOrCreateLogoTexture returns the app logo texture, creating it if needed.484 // getOrCreateLogoTexture returns the app logo texture, creating it if needed.
485 // The texture is cached under the internalDomain key.485 // The texture is cached under the internalDomain key.
486-func (a *FaviconAdapter) getOrCreateLogoTexture() *gdk.Texture {486+func (a *FaviconAdapter) getOrCreateLogoTexture(ctx context.Context) *gdk.Texture {
487 if a.internalDomain == "" || a.getLogoBytes == nil {487 if a.internalDomain == "" || a.getLogoBytes == nil {
488 return nil488 return nil
489 }489 }
@@ -495,18 +495,18 @@ func (a *FaviconAdapter) getOrCreateLogoTexture() *gdk.Texture {
495 495
496 // Create texture from logo bytes496 // Create texture from logo bytes
497 logoBytes := a.getLogoBytes()497 logoBytes := a.getLogoBytes()
498- texture := a.textureFromBytesOnGTK(logoBytes)498+ texture := a.textureFromBytesOnGTK(ctx, logoBytes)
499 if texture != nil {499 if texture != nil {
500 a.setTexture(a.internalDomain, texture)500 a.setTexture(a.internalDomain, texture)
501 }501 }
502 return texture502 return texture
503 }503 }
504 504
505-func (a *FaviconAdapter) textureFromBytesOnGTK(data []byte) *gdk.Texture {505+func (a *FaviconAdapter) textureFromBytesOnGTK(ctx context.Context, data []byte) *gdk.Texture {
506 if len(data) == 0 {506 if len(data) == 0 {
507 return nil507 return nil
508 }508 }
509- log := logging.FromContext(context.Background())509+ log := logging.FromContext(ctx)
510 log.Debug().510 log.Debug().
511 Int("bytes", len(data)).511 Int("bytes", len(data)).
512 Msg("favicon: textureFromBytesOnGTK begin")512 Msg("favicon: textureFromBytesOnGTK begin")
M internal/ui/deps.go +3 -0
@@ -114,6 +114,9 @@ func (d *Dependencies) Validate() error {
114114 if d.Engine == nil {
115115 return ErrMissingDependency("Engine")
116116 }
117+ if d.HandlerDeps.SaveConfig == nil {
118+ return ErrMissingDependency("HandlerDeps.SaveConfig")
119+ }
117120 // Use cases are optional - can be nil if not needed
118121 return nil
119122 }
@@ -114,6 +114,9 @@ func (d *Dependencies) Validate() error {
114 if d.Engine == nil {114 if d.Engine == nil {
115 return ErrMissingDependency("Engine")115 return ErrMissingDependency("Engine")
116 }116 }
117+ if d.HandlerDeps.SaveConfig == nil {
118+ return ErrMissingDependency("HandlerDeps.SaveConfig")
119+ }
117 // Use cases are optional - can be nil if not needed120 // Use cases are optional - can be nil if not needed
118 return nil121 return nil
119 }122 }
M webui/src/pages/ConfigPage.svelte +1 -1
@@ -158,7 +158,7 @@
158158 }
159159
160160 function isPerformanceSupported(cfg: ConfigDTO | null): boolean {
161- return cfg?.engine_type !== "cef";
161+ return cfg != null && cfg.engine_type !== "cef";
162162 }
163163
164164 // Load config from API
@@ -158,7 +158,7 @@
158 }158 }
159 159
160 function isPerformanceSupported(cfg: ConfigDTO | null): boolean {160 function isPerformanceSupported(cfg: ConfigDTO | null): boolean {
161- return cfg?.engine_type !== "cef";161+ return cfg != null && cfg.engine_type !== "cef";
162 }162 }
163 163
164 // Load config from API164 // Load config from API
M webui/vite-plugin-pages.ts +16 -11
@@ -32,19 +32,24 @@ function getBridgeBootstrap(): string {
3232 return nativeBridge.postMessage(msg);
3333 }
3434
35- const encoded = btoa(unescape(encodeURIComponent(JSON.stringify(msg))));
36- const response = await fetch("/api/message", {
37- method: "POST",
38- headers: {
39- "Content-Type": "application/json",
40- "X-Dumber-Body": encoded
35+ try {
36+ const encoded = btoa(unescape(encodeURIComponent(JSON.stringify(msg))));
37+ const response = await fetch("/api/message", {
38+ method: "POST",
39+ headers: {
40+ "Content-Type": "application/json",
41+ "X-Dumber-Body": encoded
42+ }
43+ });
44+ const payload = await response.json();
45+ if (payload && payload._callback && typeof window[payload._callback] === "function") {
46+ window[payload._callback](payload.data);
4147 }
42- });
43- const payload = await response.json();
44- if (payload && payload._callback && typeof window[payload._callback] === "function") {
45- window[payload._callback](payload.data);
48+ return payload;
49+ } catch (err) {
50+ console.error("[dumber:bridge] fetch bridge error:", err);
51+ return { error: err instanceof Error ? err.message : String(err) };
4652 }
47- return payload;
4853 }
4954 };
5055
@@ -32,19 +32,24 @@ function getBridgeBootstrap(): string {
32 return nativeBridge.postMessage(msg);32 return nativeBridge.postMessage(msg);
33 }33 }
34 34
35- const encoded = btoa(unescape(encodeURIComponent(JSON.stringify(msg))));35+ try {
36- const response = await fetch("/api/message", {36+ const encoded = btoa(unescape(encodeURIComponent(JSON.stringify(msg))));
37- method: "POST",37+ const response = await fetch("/api/message", {
38- headers: {38+ method: "POST",
39- "Content-Type": "application/json",39+ headers: {
40- "X-Dumber-Body": encoded40+ "Content-Type": "application/json",
41+ "X-Dumber-Body": encoded
42+ }
43+ });
44+ const payload = await response.json();
45+ if (payload && payload._callback && typeof window[payload._callback] === "function") {
46+ window[payload._callback](payload.data);
41 }47 }
42- });48+ return payload;
43- const payload = await response.json();49+ } catch (err) {
44- if (payload && payload._callback && typeof window[payload._callback] === "function") {50+ console.error("[dumber:bridge] fetch bridge error:", err);
45- window[payload._callback](payload.data);51+ return { error: err instanceof Error ? err.message : String(err) };
46 }52 }
47- return payload;
48 }53 }
49 };54 };
50 55

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