From 6f4958692aa321d5ccf671d1ff0ca34674bd0475 Mon Sep 17 00:00:00 2001 From: Tai Groot Date: Wed, 4 Feb 2026 23:41:03 +0000 Subject: [PATCH 1/2] feat(output): add asciinema (.cast) output format Add support for exporting VHS tapes to asciinema v2 format (.cast files). These recordings can be played back in the terminal using asciinema play or uploaded to asciinema.org. Usage: Output demo.cast The asciinema output can be used alongside or instead of video outputs. Closes charmbracelet/vhs#225 --- asciinema.go | 166 ++++++++++++++++++++++++++++++++++++++++++++++ asciinema_test.go | 108 ++++++++++++++++++++++++++++++ command.go | 2 + vhs.go | 79 ++++++++++++++++++---- video.go | 9 +-- 5 files changed, 348 insertions(+), 16 deletions(-) create mode 100644 asciinema.go create mode 100644 asciinema_test.go diff --git a/asciinema.go b/asciinema.go new file mode 100644 index 00000000..431f8afb --- /dev/null +++ b/asciinema.go @@ -0,0 +1,166 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +// AsciinemaHeader is the header for an asciinema v2 recording. +type AsciinemaHeader struct { + Version int `json:"version"` + Width int `json:"width"` + Height int `json:"height"` + Timestamp int64 `json:"timestamp"` + Title string `json:"title,omitempty"` + Env map[string]string `json:"env,omitempty"` +} + +// AsciinemaEvent represents a single event in an asciinema recording. +// Format: [time, "o", data] for output events. +type AsciinemaEvent struct { + Time float64 + Type string // "o" for output, "i" for input + Data string +} + +// MarshalJSON implements custom JSON marshaling for AsciinemaEvent. +func (e AsciinemaEvent) MarshalJSON() ([]byte, error) { + return json.Marshal([]interface{}{e.Time, e.Type, e.Data}) +} + +// AsciinemaRecorder captures terminal output for asciinema format. +type AsciinemaRecorder struct { + mu sync.Mutex + events []AsciinemaEvent + startTime time.Time + lastState string + output string + width int + height int + title string +} + +// NewAsciinemaRecorder creates a new asciinema recorder. +func NewAsciinemaRecorder(output string, width, height int) *AsciinemaRecorder { + return &AsciinemaRecorder{ + output: output, + width: width, + height: height, + startTime: time.Now(), + events: make([]AsciinemaEvent, 0), + } +} + +// SetTitle sets the recording title. +func (r *AsciinemaRecorder) SetTitle(title string) { + r.mu.Lock() + defer r.mu.Unlock() + r.title = title +} + +// CaptureFrame captures the current terminal state from xterm.js buffer. +// Only records if the state has changed from the previous capture. +// +// Note: Since VHS renders via xterm.js in a browser, we don't have direct +// PTY access. This captures screen snapshots and emits ANSI sequences to +// recreate the display. The result is playable but may differ from native +// asciinema recordings. +func (r *AsciinemaRecorder) CaptureFrame(lines []string) { + r.mu.Lock() + defer r.mu.Unlock() + + // Build current state for change detection + currentState := strings.Join(lines, "\n") + + // Only record if changed + if currentState == r.lastState { + return + } + + // Calculate time offset in seconds + timeOffset := time.Since(r.startTime).Seconds() + + // Emit ANSI clear screen + content + var output strings.Builder + output.WriteString("\x1b[2J\x1b[H") + + for i, line := range lines { + output.WriteString(line) + if i < len(lines)-1 { + output.WriteString("\n") + } + } + + r.events = append(r.events, AsciinemaEvent{ + Time: timeOffset, + Type: "o", + Data: output.String(), + }) + + r.lastState = currentState +} + +// Save writes the asciinema recording to the output file. +func (r *AsciinemaRecorder) Save() error { + r.mu.Lock() + defer r.mu.Unlock() + + if r.output == "" { + return nil + } + + // Ensure directory exists + if dir := filepath.Dir(r.output); dir != "." { + if err := os.MkdirAll(dir, os.ModePerm); err != nil { + return fmt.Errorf("failed to create output directory: %w", err) + } + } + + file, err := os.Create(r.output) + if err != nil { + return fmt.Errorf("failed to create asciinema file: %w", err) + } + defer file.Close() + + // Write header + header := AsciinemaHeader{ + Version: 2, + Width: r.width, + Height: r.height, + Timestamp: r.startTime.Unix(), + Title: r.title, + Env: map[string]string{ + "SHELL": "/bin/bash", + "TERM": "xterm-256color", + }, + } + + headerJSON, err := json.Marshal(header) + if err != nil { + return fmt.Errorf("failed to marshal header: %w", err) + } + fmt.Fprintln(file, string(headerJSON)) + + // Write events + for _, event := range r.events { + eventJSON, err := json.Marshal(event) + if err != nil { + continue + } + fmt.Fprintln(file, string(eventJSON)) + } + + return nil +} + +// EventCount returns the number of recorded events. +func (r *AsciinemaRecorder) EventCount() int { + r.mu.Lock() + defer r.mu.Unlock() + return len(r.events) +} diff --git a/asciinema_test.go b/asciinema_test.go new file mode 100644 index 00000000..a5962dfb --- /dev/null +++ b/asciinema_test.go @@ -0,0 +1,108 @@ +package main + +import ( + "encoding/json" + "os" + "strings" + "testing" + "time" +) + +func TestAsciinemaRecorder_CaptureFrame(t *testing.T) { + r := NewAsciinemaRecorder("", 80, 24) + + // Capture first frame + r.CaptureFrame([]string{"$ echo hello", "hello", "$"}) + if r.EventCount() != 1 { + t.Errorf("expected 1 event, got %d", r.EventCount()) + } + + // Same content should not create new event + r.CaptureFrame([]string{"$ echo hello", "hello", "$"}) + if r.EventCount() != 1 { + t.Errorf("expected 1 event (no change), got %d", r.EventCount()) + } + + // Different content should create new event + r.CaptureFrame([]string{"$ echo world", "world", "$"}) + if r.EventCount() != 2 { + t.Errorf("expected 2 events, got %d", r.EventCount()) + } +} + +func TestAsciinemaRecorder_Save(t *testing.T) { + tmpFile := t.TempDir() + "/test.cast" + + r := NewAsciinemaRecorder(tmpFile, 80, 24) + r.SetTitle("Test Recording") + + // Capture some frames + r.CaptureFrame([]string{"$ echo hello", "hello"}) + time.Sleep(100 * time.Millisecond) + r.CaptureFrame([]string{"$ echo world", "world"}) + + // Save + err := r.Save() + if err != nil { + t.Fatalf("Save failed: %v", err) + } + + // Read and verify + content, err := os.ReadFile(tmpFile) + if err != nil { + t.Fatalf("Failed to read file: %v", err) + } + + lines := strings.Split(strings.TrimSpace(string(content)), "\n") + if len(lines) < 3 { + t.Fatalf("expected at least 3 lines (header + 2 events), got %d", len(lines)) + } + + // Verify header + var header AsciinemaHeader + if err := json.Unmarshal([]byte(lines[0]), &header); err != nil { + t.Fatalf("Failed to parse header: %v", err) + } + + if header.Version != 2 { + t.Errorf("expected version 2, got %d", header.Version) + } + if header.Width != 80 { + t.Errorf("expected width 80, got %d", header.Width) + } + if header.Height != 24 { + t.Errorf("expected height 24, got %d", header.Height) + } + if header.Title != "Test Recording" { + t.Errorf("expected title 'Test Recording', got %q", header.Title) + } + + // Verify events are parseable + for i, line := range lines[1:] { + var event []interface{} + if err := json.Unmarshal([]byte(line), &event); err != nil { + t.Errorf("Failed to parse event %d: %v", i, err) + } + if len(event) != 3 { + t.Errorf("Event %d: expected 3 elements, got %d", i, len(event)) + } + } +} + +func TestAsciinemaEvent_MarshalJSON(t *testing.T) { + event := AsciinemaEvent{ + Time: 1.5, + Type: "o", + Data: "hello\n", + } + + data, err := json.Marshal(event) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + + expected := `[1.5,"o","hello\n"]` + if string(data) != expected { + t.Errorf("expected %q, got %q", expected, string(data)) + } +} diff --git a/command.go b/command.go index 987df2f7..84c3438b 100644 --- a/command.go +++ b/command.go @@ -366,6 +366,8 @@ func ExecuteOutput(c parser.Command, v *VHS) error { v.Options.Video.Output.Frames = c.Args case ".webm": v.Options.Video.Output.WebM = c.Args + case ".cast": + v.Options.Video.Output.Asciinema = c.Args default: v.Options.Video.Output.GIF = c.Args } diff --git a/vhs.go b/vhs.go index 383b0cfc..7f8f7c04 100644 --- a/vhs.go +++ b/vhs.go @@ -21,18 +21,19 @@ import ( // VHS is the object that controls the setup. type VHS struct { - Options *Options - Errors []error - Page *rod.Page - browser *rod.Browser - TextCanvas *rod.Element - CursorCanvas *rod.Element - mutex *sync.Mutex - started bool - recording bool - tty *exec.Cmd - totalFrames int - close func() error + Options *Options + Errors []error + Page *rod.Page + browser *rod.Browser + TextCanvas *rod.Element + CursorCanvas *rod.Element + mutex *sync.Mutex + started bool + recording bool + tty *exec.Cmd + totalFrames int + close func() error + AsciinemaRecorder *AsciinemaRecorder } // Options is the set of options for the setup. @@ -188,6 +189,26 @@ func (vhs *VHS) Setup() { _ = os.RemoveAll(vhs.Options.Video.Input) _ = os.MkdirAll(vhs.Options.Video.Input, 0o750) + + // Initialize asciinema recorder if output is set + if vhs.Options.Video.Output.Asciinema != "" { + // Get terminal dimensions from xterm.js + cols, _ := vhs.Page.Eval("() => term.cols") + rows, _ := vhs.Page.Eval("() => term.rows") + colsInt := 80 + rowsInt := 24 + if cols.Value.Int() > 0 { + colsInt = cols.Value.Int() + } + if rows.Value.Int() > 0 { + rowsInt = rows.Value.Int() + } + vhs.AsciinemaRecorder = NewAsciinemaRecorder( + vhs.Options.Video.Output.Asciinema, + colsInt, + rowsInt, + ) + } } const cleanupWaitTime = 100 * time.Millisecond @@ -221,6 +242,15 @@ func (vhs *VHS) Cleanup() error { // Render starts rendering the individual frames into a video. func (vhs *VHS) Render() error { + // Save asciinema recording if active + if vhs.AsciinemaRecorder != nil { + fmt.Println("Creating asciinema recording...") + if err := vhs.AsciinemaRecorder.Save(); err != nil { + return fmt.Errorf("failed to save asciinema recording: %w", err) + } + fmt.Printf("Saved asciinema recording with %d events\n", vhs.AsciinemaRecorder.EventCount()) + } + // Apply Loop Offset by modifying frame sequence if err := vhs.ApplyLoopOffset(); err != nil { return err @@ -351,6 +381,11 @@ func (vhs *VHS) Record(ctx context.Context) <-chan error { continue } + // Capture asciinema frame if recorder is active + if vhs.AsciinemaRecorder != nil { + vhs.captureAsciinemaFrame() + } + cursor, cursorErr := vhs.CursorCanvas.CanvasToImage("image/png", quality) text, textErr := vhs.TextCanvas.CanvasToImage("image/png", quality) if textErr != nil || cursorErr != nil { @@ -387,6 +422,26 @@ func (vhs *VHS) Record(ctx context.Context) <-chan error { return ch } +// captureAsciinemaFrame captures the current terminal state for asciinema. +func (vhs *VHS) captureAsciinemaFrame() { + if vhs.AsciinemaRecorder == nil { + return + } + + // Get the current buffer content from xterm.js + buf, err := vhs.Page.Eval("() => Array(term.rows).fill(0).map((e, i) => term.buffer.active.getLine(i).translateToString().trimEnd())") + if err != nil { + return + } + + lines := make([]string, 0) + for _, line := range buf.Value.Arr() { + lines = append(lines, line.Str()) + } + + vhs.AsciinemaRecorder.CaptureFrame(lines) +} + // ResumeRecording indicates to VHS that the recording should be resumed. func (vhs *VHS) ResumeRecording() { vhs.mutex.Lock() diff --git a/video.go b/video.go index 8ed49299..03c9c146 100644 --- a/video.go +++ b/video.go @@ -41,10 +41,11 @@ func randomDir() string { // VideoOutputs is a mapping from file type to file path for all video outputs // of VHS. type VideoOutputs struct { - GIF string - WebM string - MP4 string - Frames string + GIF string + WebM string + MP4 string + Frames string + Asciinema string } // VideoOptions is the set of options for converting frames to a GIF. From 0242e29ab5f521f539223619e6e4a48d2370ecc4 Mon Sep 17 00:00:00 2001 From: Tai Groot Date: Thu, 19 Feb 2026 01:22:26 -0500 Subject: [PATCH 2/2] fixup code style for PR --- asciinema.go | 10 +++++----- vhs.go | 5 +++-- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/asciinema.go b/asciinema.go index 431f8afb..493daf56 100644 --- a/asciinema.go +++ b/asciinema.go @@ -30,7 +30,7 @@ type AsciinemaEvent struct { // MarshalJSON implements custom JSON marshaling for AsciinemaEvent. func (e AsciinemaEvent) MarshalJSON() ([]byte, error) { - return json.Marshal([]interface{}{e.Time, e.Type, e.Data}) + return json.Marshal([]interface{}{e.Time, e.Type, e.Data}) //nolint:wrapcheck } // AsciinemaRecorder captures terminal output for asciinema format. @@ -116,7 +116,7 @@ func (r *AsciinemaRecorder) Save() error { // Ensure directory exists if dir := filepath.Dir(r.output); dir != "." { - if err := os.MkdirAll(dir, os.ModePerm); err != nil { + if err := os.MkdirAll(dir, 0o750); err != nil { return fmt.Errorf("failed to create output directory: %w", err) } } @@ -125,7 +125,7 @@ func (r *AsciinemaRecorder) Save() error { if err != nil { return fmt.Errorf("failed to create asciinema file: %w", err) } - defer file.Close() + defer file.Close() //nolint:errcheck // Write header header := AsciinemaHeader{ @@ -144,7 +144,7 @@ func (r *AsciinemaRecorder) Save() error { if err != nil { return fmt.Errorf("failed to marshal header: %w", err) } - fmt.Fprintln(file, string(headerJSON)) + _, _ = fmt.Fprintln(file, string(headerJSON)) // Write events for _, event := range r.events { @@ -152,7 +152,7 @@ func (r *AsciinemaRecorder) Save() error { if err != nil { continue } - fmt.Fprintln(file, string(eventJSON)) + _, _ = fmt.Fprintln(file, string(eventJSON)) } return nil diff --git a/vhs.go b/vhs.go index 7f8f7c04..f15f280c 100644 --- a/vhs.go +++ b/vhs.go @@ -434,8 +434,9 @@ func (vhs *VHS) captureAsciinemaFrame() { return } - lines := make([]string, 0) - for _, line := range buf.Value.Arr() { + arr := buf.Value.Arr() + lines := make([]string, 0, len(arr)) + for _, line := range arr { lines = append(lines, line.Str()) }