From d9d28248e8e17d1404c6b6a0cbe5da092195f72d Mon Sep 17 00:00:00 2001 From: Dustin <6962246+djdarcy@users.noreply.github.com> Date: Fri, 13 Mar 2026 01:34:57 -0400 Subject: [PATCH 1/2] fix: add DOM renderer fallback and SwiftShader for Windows compatibility The canvas renderer fails in headless Chrome on Windows when WebGL is unavailable (RDP sessions, headless environments, CI). This causes CanvasToImage() calls to return empty frames. Changes: - Switch default renderer from canvas to DOM (tty.go) - Add SwiftShader/ANGLE browser flags for WebGL fallback (vhs.go) - Probe for canvas.xterm-text-layer with timeout; fall back to .xterm-screen element screenshot when canvas is absent (vhs.go) - Use temporary timeout page for probe to avoid context deadline errors on stored elements (vhs.go) - Add diagnostic logging throughout startup, setup, and recording pipeline (evaluator.go, vhs.go) The debug-instrumented version is preserved in this commit for reference. A follow-up commit strips the diagnostic logging. Related: charmbracelet/vhs#631 Depends on: tsl0922/ttyd#1501 (ConPTY fix for Windows 11) --- evaluator.go | 10 +++++++- tty.go | 2 +- vhs.go | 68 +++++++++++++++++++++++++++++++++++++++++++++------- 3 files changed, 70 insertions(+), 10 deletions(-) diff --git a/evaluator.go b/evaluator.go index 5768b854..daaafe28 100644 --- a/evaluator.go +++ b/evaluator.go @@ -47,10 +47,12 @@ func Evaluate(ctx context.Context, tape string, out io.Writer, opts ...Evaluator // Let's wait until we can access the window.term variable. // // This is necessary because some SET commands modify the terminal. + log.Printf("[DEBUG] Waiting for window.term to initialize...") err := v.Page.Wait(rod.Eval("() => window.term != undefined")) if err != nil { return []error{err} } + log.Printf("[DEBUG] window.term is ready") var offset int for i, cmd := range cmds { @@ -91,7 +93,9 @@ func Evaluate(ctx context.Context, tape string, out io.Writer, opts ...Evaluator } // Setup the terminal session so we can start executing commands. + log.Printf("[DEBUG] Running v.Setup()...") v.Setup() + log.Printf("[DEBUG] Setup complete") // If the first command (after Settings and Outputs) is a Hide command, we can // begin executing the commands before we start recording to avoid capturing @@ -111,8 +115,10 @@ func Evaluate(ctx context.Context, tape string, out io.Writer, opts ...Evaluator } // Begin recording frames as we are now in a recording state. + log.Printf("[DEBUG] Starting recording...") ctx, cancel := context.WithCancel(ctx) //nolint:gosec ch := v.Record(ctx) + log.Printf("[DEBUG] Recording started") // Clean up temporary files at the end. defer func() { @@ -138,7 +144,9 @@ func Evaluate(ctx context.Context, tape string, out io.Writer, opts ...Evaluator } }() - for _, cmd := range cmds[offset:] { + log.Printf("[DEBUG] Executing %d commands starting at offset %d", len(cmds)-offset, offset) + for ci, cmd := range cmds[offset:] { + log.Printf("[DEBUG] Command %d: type=%v options=%q args=%q", ci, cmd.Type, cmd.Options, cmd.Args) if ctx.Err() != nil { teardown() return []error{ctx.Err()} diff --git a/tty.go b/tty.go index c8af5dea..c6409974 100644 --- a/tty.go +++ b/tty.go @@ -28,7 +28,7 @@ func buildTtyCmd(port int, shell Shell) *exec.Cmd { args := []string{ //nolint:prealloc fmt.Sprintf("--port=%d", port), "--interface", "127.0.0.1", - "-t", "rendererType=canvas", + "-t", "rendererType=dom", "-t", "disableResizeOverlay=true", "-t", "enableSixel=true", "-t", "customGlyphs=true", diff --git a/vhs.go b/vhs.go index 235e7b42..0a86ed34 100644 --- a/vhs.go +++ b/vhs.go @@ -27,6 +27,8 @@ type VHS struct { browser *rod.Browser TextCanvas *rod.Element CursorCanvas *rod.Element + Screen *rod.Element // fallback for DOM renderer (no canvas) + useDOMFallback bool mutex *sync.Mutex started bool recording bool @@ -131,22 +133,36 @@ func (vhs *VHS) Start() error { } port := randomPort() + log.Printf("[DEBUG] Starting ttyd on port %d", port) vhs.tty = buildTtyCmd(port, vhs.Options.Shell) if err := vhs.tty.Start(); err != nil { return fmt.Errorf("could not start tty: %w", err) } + log.Printf("[DEBUG] ttyd started (pid %d)", vhs.tty.Process.Pid) path, _ := launcher.LookPath() + log.Printf("[DEBUG] Browser path: %s", path) enableNoSandbox := os.Getenv("VHS_NO_SANDBOX") != "" - u, err := launcher.New().Leakless(false).Bin(path).NoSandbox(enableNoSandbox).Launch() + log.Printf("[DEBUG] Launching browser (no-sandbox=%v)...", enableNoSandbox) + u, err := launcher.New(). + Leakless(false). + Bin(path). + NoSandbox(enableNoSandbox). + Set("use-gl", "angle"). + Set("use-angle", "swiftshader"). + Set("enable-webgl"). + Launch() if err != nil { return fmt.Errorf("could not launch browser: %w", err) } + log.Printf("[DEBUG] Browser launched, control URL: %s", u) browser := rod.New().ControlURL(u).MustConnect() + log.Printf("[DEBUG] Browser connected, opening page http://localhost:%d", port) page, err := browser.Page(proto.TargetCreateTarget{URL: fmt.Sprintf("http://localhost:%d", port)}) if err != nil { return fmt.Errorf("could not open ttyd: %w", err) } + log.Printf("[DEBUG] Page opened successfully") vhs.browser = browser vhs.Page = page @@ -172,10 +188,34 @@ func (vhs *VHS) Setup() { width := vhs.Options.Video.Style.Width - double(padding) - double(margin) height := vhs.Options.Video.Style.Height - double(padding) - double(margin) - bar vhs.Page = vhs.Page.MustSetViewport(width, height, 0, false) + log.Printf("[DEBUG] Viewport set to %dx%d", width, height) // Find xterm.js canvases for the text and cursor layer for recording. - vhs.TextCanvas, _ = vhs.Page.Element("canvas.xterm-text-layer") - vhs.CursorCanvas, _ = vhs.Page.Element("canvas.xterm-cursor-layer") + // Newer versions of xterm.js (bundled with ttyd 1.7.7+) may use a DOM + // renderer instead of canvas when WebGL is unavailable (e.g. headless + // Chrome, RDP sessions). In that case, fall back to screenshotting + // the .xterm-screen element. + // + // IMPORTANT: Use a temporary timeout page for the Element() lookup only. + // Do NOT store elements obtained from Timeout() -- their context expires, + // causing all subsequent Screenshot()/CanvasToImage() calls to fail with + // "context deadline exceeded". + log.Printf("[DEBUG] Looking for canvas.xterm-text-layer...") + probe, _ := vhs.Page.Timeout(5 * time.Second).Element("canvas.xterm-text-layer") + if probe != nil { + log.Printf("[DEBUG] Found canvas text layer (canvas renderer)") + vhs.TextCanvas, _ = vhs.Page.Element("canvas.xterm-text-layer") + vhs.CursorCanvas, _ = vhs.Page.Element("canvas.xterm-cursor-layer") + } else { + log.Printf("[DEBUG] Canvas not found, trying DOM renderer fallback (.xterm-screen)...") + vhs.Screen, _ = vhs.Page.Element(".xterm-screen") + if vhs.Screen != nil { + vhs.useDOMFallback = true + log.Printf("[DEBUG] Using DOM renderer fallback") + } else { + log.Printf("[DEBUG] WARNING: Neither canvas nor DOM screen element found") + } + } // Apply options to the terminal // By this point the setting commands have been executed, so the `opts` struct is up to date. @@ -351,11 +391,23 @@ func (vhs *VHS) Record(ctx context.Context) <-chan error { continue } - cursor, cursorErr := vhs.CursorCanvas.CanvasToImage("image/png", quality) - text, textErr := vhs.TextCanvas.CanvasToImage("image/png", quality) - if textErr != nil || cursorErr != nil { - ch <- fmt.Errorf("error: %v, %v", textErr, cursorErr) - continue + var text, cursor []byte + if vhs.useDOMFallback { + screenPng, screenErr := vhs.Screen.Screenshot(proto.PageCaptureScreenshotFormatPng, quality) + if screenErr != nil { + ch <- fmt.Errorf("error capturing screen: %v", screenErr) + continue + } + text = screenPng + cursor = screenPng + } else { + var cursorErr, textErr error + cursor, cursorErr = vhs.CursorCanvas.CanvasToImage("image/png", quality) + text, textErr = vhs.TextCanvas.CanvasToImage("image/png", quality) + if textErr != nil || cursorErr != nil { + ch <- fmt.Errorf("error: %v, %v", textErr, cursorErr) + continue + } } counter++ From bcd880db7c1df110146c098b382a0e49d9924a32 Mon Sep 17 00:00:00 2001 From: Dustin <6962246+djdarcy@users.noreply.github.com> Date: Fri, 13 Mar 2026 01:53:54 -0400 Subject: [PATCH 2/2] fix: strip diagnostic logging for upstream PR Remove verbose debug output from evaluator, browser launch, setup, and recording pipeline. Functional changes preserved: DOM renderer fallback, SwiftShader browser flags, timeout-based canvas probe. The debug-instrumented version is preserved in the previous commit for reference. --- evaluator.go | 10 +--------- vhs.go | 21 +++------------------ 2 files changed, 4 insertions(+), 27 deletions(-) diff --git a/evaluator.go b/evaluator.go index daaafe28..5768b854 100644 --- a/evaluator.go +++ b/evaluator.go @@ -47,12 +47,10 @@ func Evaluate(ctx context.Context, tape string, out io.Writer, opts ...Evaluator // Let's wait until we can access the window.term variable. // // This is necessary because some SET commands modify the terminal. - log.Printf("[DEBUG] Waiting for window.term to initialize...") err := v.Page.Wait(rod.Eval("() => window.term != undefined")) if err != nil { return []error{err} } - log.Printf("[DEBUG] window.term is ready") var offset int for i, cmd := range cmds { @@ -93,9 +91,7 @@ func Evaluate(ctx context.Context, tape string, out io.Writer, opts ...Evaluator } // Setup the terminal session so we can start executing commands. - log.Printf("[DEBUG] Running v.Setup()...") v.Setup() - log.Printf("[DEBUG] Setup complete") // If the first command (after Settings and Outputs) is a Hide command, we can // begin executing the commands before we start recording to avoid capturing @@ -115,10 +111,8 @@ func Evaluate(ctx context.Context, tape string, out io.Writer, opts ...Evaluator } // Begin recording frames as we are now in a recording state. - log.Printf("[DEBUG] Starting recording...") ctx, cancel := context.WithCancel(ctx) //nolint:gosec ch := v.Record(ctx) - log.Printf("[DEBUG] Recording started") // Clean up temporary files at the end. defer func() { @@ -144,9 +138,7 @@ func Evaluate(ctx context.Context, tape string, out io.Writer, opts ...Evaluator } }() - log.Printf("[DEBUG] Executing %d commands starting at offset %d", len(cmds)-offset, offset) - for ci, cmd := range cmds[offset:] { - log.Printf("[DEBUG] Command %d: type=%v options=%q args=%q", ci, cmd.Type, cmd.Options, cmd.Args) + for _, cmd := range cmds[offset:] { if ctx.Err() != nil { teardown() return []error{ctx.Err()} diff --git a/vhs.go b/vhs.go index 0a86ed34..61d40c23 100644 --- a/vhs.go +++ b/vhs.go @@ -133,17 +133,13 @@ func (vhs *VHS) Start() error { } port := randomPort() - log.Printf("[DEBUG] Starting ttyd on port %d", port) vhs.tty = buildTtyCmd(port, vhs.Options.Shell) if err := vhs.tty.Start(); err != nil { return fmt.Errorf("could not start tty: %w", err) } - log.Printf("[DEBUG] ttyd started (pid %d)", vhs.tty.Process.Pid) path, _ := launcher.LookPath() - log.Printf("[DEBUG] Browser path: %s", path) enableNoSandbox := os.Getenv("VHS_NO_SANDBOX") != "" - log.Printf("[DEBUG] Launching browser (no-sandbox=%v)...", enableNoSandbox) u, err := launcher.New(). Leakless(false). Bin(path). @@ -155,14 +151,11 @@ func (vhs *VHS) Start() error { if err != nil { return fmt.Errorf("could not launch browser: %w", err) } - log.Printf("[DEBUG] Browser launched, control URL: %s", u) browser := rod.New().ControlURL(u).MustConnect() - log.Printf("[DEBUG] Browser connected, opening page http://localhost:%d", port) page, err := browser.Page(proto.TargetCreateTarget{URL: fmt.Sprintf("http://localhost:%d", port)}) if err != nil { return fmt.Errorf("could not open ttyd: %w", err) } - log.Printf("[DEBUG] Page opened successfully") vhs.browser = browser vhs.Page = page @@ -188,7 +181,6 @@ func (vhs *VHS) Setup() { width := vhs.Options.Video.Style.Width - double(padding) - double(margin) height := vhs.Options.Video.Style.Height - double(padding) - double(margin) - bar vhs.Page = vhs.Page.MustSetViewport(width, height, 0, false) - log.Printf("[DEBUG] Viewport set to %dx%d", width, height) // Find xterm.js canvases for the text and cursor layer for recording. // Newer versions of xterm.js (bundled with ttyd 1.7.7+) may use a DOM @@ -196,24 +188,17 @@ func (vhs *VHS) Setup() { // Chrome, RDP sessions). In that case, fall back to screenshotting // the .xterm-screen element. // - // IMPORTANT: Use a temporary timeout page for the Element() lookup only. - // Do NOT store elements obtained from Timeout() -- their context expires, - // causing all subsequent Screenshot()/CanvasToImage() calls to fail with - // "context deadline exceeded". - log.Printf("[DEBUG] Looking for canvas.xterm-text-layer...") + // Use a temporary timeout page for the Element() lookup only. + // Do not store elements obtained from Timeout() -- their context expires, + // causing all subsequent Screenshot()/CanvasToImage() calls to fail. probe, _ := vhs.Page.Timeout(5 * time.Second).Element("canvas.xterm-text-layer") if probe != nil { - log.Printf("[DEBUG] Found canvas text layer (canvas renderer)") vhs.TextCanvas, _ = vhs.Page.Element("canvas.xterm-text-layer") vhs.CursorCanvas, _ = vhs.Page.Element("canvas.xterm-cursor-layer") } else { - log.Printf("[DEBUG] Canvas not found, trying DOM renderer fallback (.xterm-screen)...") vhs.Screen, _ = vhs.Page.Element(".xterm-screen") if vhs.Screen != nil { vhs.useDOMFallback = true - log.Printf("[DEBUG] Using DOM renderer fallback") - } else { - log.Printf("[DEBUG] WARNING: Neither canvas nor DOM screen element found") } }