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
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -756,13 +756,19 @@ Type "You will see this being typed."

### Screenshot

The `Screenshot` command captures the current frame (png format).
The `Screenshot` command captures the current frame in either PNG or text format.

```elixir
# At any point...
# Capture as PNG image
Screenshot examples/screenshot.png

# Capture as plain text
Screenshot examples/screenshot.txt
```

When using `.png` extension, VHS captures a visual screenshot of the terminal.
When using `.txt` extension, VHS captures the terminal content as plain text without any styling.
Comment on lines +769 to +770
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing docs for .ansi

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will add!


### Copy / Paste

The `Copy` and `Paste` copy and paste the string from clipboard.
Expand Down
21 changes: 21 additions & 0 deletions examples/text-screenshot.tape
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
Output test_output.gif

Set FontSize 20
Set Width 800
Set Height 400

Type "echo 'Hello World'"
Enter
Sleep 0.5s

Screenshot test_screenshot.txt

Sleep 0.5s

Type "ls -la"
Enter
Sleep 1s

Screenshot test_screenshot2.png
Sleep 0.1s
Screenshot test_screenshot2.txt
6 changes: 3 additions & 3 deletions parser/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -755,10 +755,10 @@ func (p *Parser) parseScreenshot() Command {

path := p.peek.Literal

// Check if path has .png extension
// Check if path has .png, .txt, or .ansi extension
ext := filepath.Ext(path)
if ext != ".png" {
p.errors = append(p.errors, NewError(p.peek, "Expected file with .png extension"))
if ext != ".png" && ext != ".txt" && ext != ".ansi" {
p.errors = append(p.errors, NewError(p.peek, "Expected file with .png, .txt, or .ansi extension"))
p.nextToken()
return cmd
}
Expand Down
2 changes: 1 addition & 1 deletion parser/parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -412,7 +412,7 @@ func TestParseScreeenshot(t *testing.T) {
t.Run("should return error when screenshot extension is NOT (.png)", func(t *testing.T) {
test := &parseScreenshotTest{
tape: "Screenshot step_one_screenshot.jpg",
errors: []string{"Expected file with .png extension"},
errors: []string{"Expected file with .png, .txt, or .ansi extension"},
}

test.run(t)
Expand Down
57 changes: 57 additions & 0 deletions screenshot.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import (
"fmt"
"os"
"os/exec"
"path/filepath"
)
Expand All @@ -17,6 +18,12 @@
// screenshots represents a map holding screenshot path as key and frame as value.
screenshots map[string]int

// textScreenshots represents a map holding text screenshot path as key and content as value.
textScreenshots map[string]string

// ansiScreenshots represents a map holding ANSI screenshot path as key and content as value.
ansiScreenshots map[string]string

// Input represents location of cursor and text frames png files.
input string

Expand All @@ -29,6 +36,8 @@
frameCapture: false,
nextScreenshotPath: "",
screenshots: make(map[string]int),
textScreenshots: make(map[string]string),
ansiScreenshots: make(map[string]string),
input: input,
style: style,
}
Expand All @@ -43,6 +52,24 @@
opts.nextScreenshotPath = ""
}

// makeTextScreenshot stores text content for a text screenshot.
// After storing content it disables frame capture.
func (opts *ScreenshotOptions) makeTextScreenshot(content string) {
opts.textScreenshots[opts.nextScreenshotPath] = content

opts.frameCapture = false
opts.nextScreenshotPath = ""
}

// makeAnsiScreenshot stores ANSI content for an ANSI screenshot.
// After storing content it disables frame capture.
func (opts *ScreenshotOptions) makeAnsiScreenshot(content string) {
opts.ansiScreenshots[opts.nextScreenshotPath] = content

opts.frameCapture = false
opts.nextScreenshotPath = ""
}

// captureNextFrame prepares capture of next frame by given path.
func (opts *ScreenshotOptions) enableFrameCapture(path string) {
opts.frameCapture = true
Expand All @@ -54,6 +81,12 @@
cmds := []*exec.Cmd{}

for path, frame := range opts.screenshots {
// Handle text format screenshots differently
if filepath.Ext(path) == ".txt" {

Check failure on line 85 in screenshot.go

View workflow job for this annotation

GitHub Actions / lint / lint (ubuntu-latest)

string `.txt` has 3 occurrences, make it a constant (goconst)
// Text screenshots don't need ffmpeg, they'll be handled elsewhere
continue
}

cursorStream := filepath.Join(opts.input, fmt.Sprintf(cursorFrameFormat, frame))
textStream := filepath.Join(opts.input, fmt.Sprintf(textFrameFormat, frame))

Expand Down Expand Up @@ -99,3 +132,27 @@

return args
}

// MakeTextScreenshots writes text screenshots that were captured during recording.
func MakeTextScreenshots(opts ScreenshotOptions) error {
for path, content := range opts.textScreenshots {
// Write to file
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {

Check failure on line 140 in screenshot.go

View workflow job for this annotation

GitHub Actions / lint / lint (ubuntu-latest)

G306: Expect WriteFile permissions to be 0600 or less (gosec)
return fmt.Errorf("failed to write text screenshot to %s: %w", path, err)
}
}

return nil
}

// MakeAnsiScreenshots writes ANSI screenshots that were captured during recording.
func MakeAnsiScreenshots(opts ScreenshotOptions) error {
for path, content := range opts.ansiScreenshots {
// Write to file
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {

Check failure on line 152 in screenshot.go

View workflow job for this annotation

GitHub Actions / lint / lint (ubuntu-latest)

G306: Expect WriteFile permissions to be 0600 or less (gosec)
return fmt.Errorf("failed to write ANSI screenshot to %s: %w", path, err)
}
}

return nil
}
14 changes: 14 additions & 0 deletions screenshot_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,18 @@ func TestScreenshot(t *testing.T) {
t.Errorf("nextScreenshotPath: %s, expected: %s", opts.nextScreenshotPath, path)
}
})

t.Run("MakeTextScreenshots should process text screenshots", func(t *testing.T) {
opts := ScreenshotOptions{
textScreenshots: map[string]string{
"test.txt": "Hello World\nLine 2",
},
}

// This should not error because we're just writing files
err := MakeTextScreenshots(opts)
if err != nil {
t.Errorf("Expected no error when processing text screenshots, got: %v", err)
}
})
}
50 changes: 50 additions & 0 deletions testing.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,56 @@ func (v *VHS) Buffer() ([]string, error) {
return lines, nil
}

// AnsiBuffer returns the current buffer with ANSI escape codes preserved.
func (v *VHS) AnsiBuffer() ([]string, error) {
// Get the current buffer with ANSI codes preserved using a different approach
// Since translateToString(true) might not work, we'll get the raw buffer data
buf, err := v.Page.Eval(`() => {
const lines = [];
for (let i = 0; i < term.rows; i++) {
const line = term.buffer.active.getLine(i);
if (line) {
// Get the line with formatting preserved
let lineStr = '';
for (let j = 0; j < line.length; j++) {
const cell = line.getCell(j);
if (cell) {
// Add ANSI codes for foreground color
if (cell.getFgColor() !== 0) {
lineStr += '\033[38;5;' + cell.getFgColor() + 'm';
}
// Add ANSI codes for background color
if (cell.getBgColor() !== 0) {
lineStr += '\033[48;5;' + cell.getBgColor() + 'm';
}
// Add the character
lineStr += cell.getChars() || ' ';
// Reset if we had colors
if (cell.getFgColor() !== 0 || cell.getBgColor() !== 0) {
lineStr += '\033[0m';
}
}
}
lines.push(lineStr.trimEnd());
} else {
lines.push('');
}
}
return lines;
}`)
if err != nil {
return nil, fmt.Errorf("read ANSI buffer: %w", err)
}

arr := buf.Value.Arr()
lines := make([]string, 0, len(arr))
for _, line := range arr {
lines = append(lines, line.Str())
}

return lines, nil
}

// CurrentLine returns the current line from the buffer.
func (v *VHS) CurrentLine() (string, error) {
buf, err := v.Page.Eval("() => term.buffer.active.getLine(term.buffer.active.cursorY+term.buffer.active.viewportY).translateToString().trimEnd()")
Expand Down
33 changes: 32 additions & 1 deletion vhs.go
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,16 @@
}

// Generate the video(s) with the frames.
// Generate text screenshots first
if err := MakeTextScreenshots(vhs.Options.Screenshot); err != nil {
return fmt.Errorf("failed to create text screenshots: %w", err)
}

// Generate ANSI screenshots
if err := MakeAnsiScreenshots(vhs.Options.Screenshot); err != nil {
return fmt.Errorf("failed to create ANSI screenshots: %w", err)
}

var cmds []*exec.Cmd
cmds = append(cmds, MakeGIF(vhs.Options.Video))
cmds = append(cmds, MakeMP4(vhs.Options.Video))
Expand Down Expand Up @@ -377,8 +387,29 @@
}

// Capture current frame and disable frame capturing
if vhs.Options.Screenshot.frameCapture {

Check failure on line 390 in vhs.go

View workflow job for this annotation

GitHub Actions / lint / lint (ubuntu-latest)

`if vhs.Options.Screenshot.frameCapture` has complex nested blocks (complexity: 7) (nestif)
vhs.Options.Screenshot.makeScreenshot(counter)
// Check if this is a text screenshot
if filepath.Ext(vhs.Options.Screenshot.nextScreenshotPath) == ".txt" {
// Get terminal buffer content
buffer, err := vhs.Buffer()
if err != nil {
ch <- fmt.Errorf("error capturing text screenshot: %w", err)
continue
}
content := strings.Join(buffer, "\n")
vhs.Options.Screenshot.makeTextScreenshot(content)
} else if filepath.Ext(vhs.Options.Screenshot.nextScreenshotPath) == ".ansi" {
// Get terminal buffer content with ANSI codes
buffer, err := vhs.AnsiBuffer()
if err != nil {
ch <- fmt.Errorf("error capturing ANSI screenshot: %w", err)
continue
}
content := strings.Join(buffer, "\n")
vhs.Options.Screenshot.makeAnsiScreenshot(content)
} else {
vhs.Options.Screenshot.makeScreenshot(counter)
}
}
}
}
Expand Down
Loading