diff --git a/asciinema.go b/asciinema.go new file mode 100644 index 00000000..493daf56 --- /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}) //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) +} 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..f15f280c 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,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() 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.