Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 166 additions & 0 deletions asciinema.go
Original file line number Diff line number Diff line change
@@ -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}) //nolint:wrapcheck
}

// 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, 0o750); 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() //nolint:errcheck

// 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)
}
108 changes: 108 additions & 0 deletions asciinema_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
}
2 changes: 2 additions & 0 deletions command.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
80 changes: 68 additions & 12 deletions vhs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -387,6 +422,27 @@ 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
}

arr := buf.Value.Arr()
lines := make([]string, 0, len(arr))
for _, line := range 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()
Expand Down
Loading
Loading