Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -130,9 +130,15 @@ func (s *RecordingMetadataService) ProcessSessionRecording(ctx context.Context,
sampler := newThumbnailBucketSampler(maxThumbnails, thumbnailInterval)

recordThumbnail := func(start time.Time) {
state := vt.DumpState()

sampler.add(&state, start)
cols, rows := vt.Size()

sampler.add(&thumbnailState{
svg: terminal.VtToSvg(vt),
cols: cols,
rows: rows,
cursorVisible: vt.CursorVisible(),
cursor: vt.Cursor(),
}, start)
}

var hasSeenPrintEvent bool
Expand Down Expand Up @@ -333,12 +339,12 @@ func (s *RecordingMetadataService) upload(ctx context.Context, sessionID session

func thumbnailEntryToProto(t *thumbnailEntry) *pb.SessionRecordingThumbnail {
return &pb.SessionRecordingThumbnail{
Svg: terminal.VtStateToSvg(t.state),
Cols: int32(t.state.Cols),
Rows: int32(t.state.Rows),
CursorX: int32(t.state.CursorX),
CursorY: int32(t.state.CursorY),
CursorVisible: t.state.CursorVisible,
Svg: t.state.svg,
Cols: int32(t.state.cols),
Rows: int32(t.state.rows),
CursorX: int32(t.state.cursor.X),
CursorY: int32(t.state.cursor.Y),
CursorVisible: t.state.cursorVisible,
StartOffset: durationpb.New(t.startOffset),
EndOffset: durationpb.New(t.endOffset),
}
Expand Down
11 changes: 9 additions & 2 deletions lib/auth/recordingmetadata/recordingmetadatav1/thumbnails.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,15 @@ type thumbnailBucketSampler struct {
startTime time.Time
}

type thumbnailState struct {
svg []byte
cols, rows int
cursorVisible bool
cursor vt10x.Cursor
}

type thumbnailEntry struct {
state *vt10x.TerminalState
state *thumbnailState
startOffset time.Duration
endOffset time.Duration
timestamp time.Time
Expand All @@ -56,7 +63,7 @@ func (s *thumbnailBucketSampler) shouldCapture(timestamp time.Time) bool {
return !timestamp.Before(s.nextTimestamp)
}

func (s *thumbnailBucketSampler) add(state *vt10x.TerminalState, timestamp time.Time) {
func (s *thumbnailBucketSampler) add(state *thumbnailState, timestamp time.Time) {
if s.startTime.IsZero() {
s.startTime = timestamp
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ import (
"testing"
"time"

"github.com/hinshun/vt10x"
"github.com/stretchr/testify/require"
)

Expand All @@ -32,7 +31,7 @@ func TestThumbnailBucketSampler_ShouldCapture(t *testing.T) {

require.True(t, sampler.shouldCapture(baseTime))

sampler.add(&vt10x.TerminalState{}, baseTime)
sampler.add(&thumbnailState{}, baseTime)

require.False(t, sampler.shouldCapture(baseTime.Add(500*time.Millisecond)))
require.False(t, sampler.shouldCapture(baseTime.Add(999*time.Millisecond)))
Expand All @@ -44,9 +43,9 @@ func TestThumbnailBucketSampler_BasicAdd(t *testing.T) {
sampler := newThumbnailBucketSampler(10, time.Second)
baseTime := time.Now()

sampler.add(&vt10x.TerminalState{}, baseTime)
sampler.add(&vt10x.TerminalState{}, baseTime.Add(time.Second))
sampler.add(&vt10x.TerminalState{}, baseTime.Add(2*time.Second))
sampler.add(&thumbnailState{}, baseTime)
sampler.add(&thumbnailState{}, baseTime.Add(time.Second))
sampler.add(&thumbnailState{}, baseTime.Add(2*time.Second))

result := sampler.result()
require.Len(t, result, 3)
Expand All @@ -69,13 +68,13 @@ func TestThumbnailBucketSampler_AdaptInterval(t *testing.T) {
baseTime := time.Now()

for i := 0; i < 4; i++ {
sampler.add(&vt10x.TerminalState{}, baseTime.Add(time.Duration(i)*time.Second))
sampler.add(&thumbnailState{}, baseTime.Add(time.Duration(i)*time.Second))
}

require.Len(t, sampler.entries, 4)
require.Equal(t, time.Second, sampler.interval)

sampler.add(&vt10x.TerminalState{}, baseTime.Add(4*time.Second))
sampler.add(&thumbnailState{}, baseTime.Add(4*time.Second))

require.Len(t, sampler.entries, 3)
require.Equal(t, 2*time.Second, sampler.interval)
Expand Down Expand Up @@ -115,7 +114,7 @@ func TestThumbnailBucketSampler_MultipleAdaptations(t *testing.T) {
// Add 1500ms (4 entries)

for i := 0; i < 16; i++ {
sampler.add(&vt10x.TerminalState{}, baseTime.Add(time.Duration(i)*100*time.Millisecond))
sampler.add(&thumbnailState{}, baseTime.Add(time.Duration(i)*100*time.Millisecond))
}

require.Equal(t, 6400*time.Millisecond, sampler.interval)
Expand Down
45 changes: 22 additions & 23 deletions lib/terminal/svg.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,11 @@ const (
charRatio = 0.602 // Menlo mono font character width/height ratio
)

// VtStateToSvg converts a terminal state to an SVG representation
func VtStateToSvg(state *vt10x.TerminalState) []byte {
// VtToSvg converts a terminal state to an SVG representation
func VtToSvg(terminal vt10x.Terminal) []byte {
var buf bytes.Buffer

cols, rows := state.Cols, state.Rows
cols, rows := terminal.Size()

charWidthPx := fontSize * charRatio
rowHeightPx := fontSize * lineHeight
Expand All @@ -45,12 +45,13 @@ func VtStateToSvg(state *vt10x.TerminalState) []byte {
closeHeader := writeSVGHeader(&buf, pixelWidth, pixelHeight)

var cursor *cursorPos
if state.CursorVisible {
cursor = &cursorPos{x: state.CursorX, y: state.CursorY}
if terminal.CursorVisible() {
c := terminal.Cursor()
cursor = &cursorPos{x: c.X, y: c.Y}
}

renderBackgrounds(&buf, state.PrimaryBuffer, cols, rows, charWidthPx, rowHeightPx, cursor)
renderText(&buf, state.PrimaryBuffer, cols, rows, charWidthPx, rowHeightPx, cursor)
renderBackgrounds(&buf, terminal, charWidthPx, rowHeightPx, cursor)
renderText(&buf, terminal, charWidthPx, rowHeightPx, cursor)

buf.WriteString(closeHeader())

Expand All @@ -73,23 +74,20 @@ func writeSVGHeader(buf *bytes.Buffer, width, height int) func() string {
}
}

func renderBackgrounds(buf *bytes.Buffer, buffer [][]vt10x.Glyph, cols, rows int, charWidthPx, rowHeightPx float64, cursor *cursorPos) {
func renderBackgrounds(buf *bytes.Buffer, terminal vt10x.Terminal, charWidthPx, rowHeightPx float64, cursor *cursorPos) {
type bgRect struct {
x, y, w, h float64
color vt10x.Color
}
var rects []bgRect

for y := 0; y < len(buffer); y++ {
if y >= len(buffer) {
continue
}

cols, rows := terminal.Size()
for y := range rows {
yPos := float64(y) * rowHeightPx
var currentRect *bgRect

for x := 0; x < len(buffer[y]); x++ {
glyph := buffer[y][x]
for x := range cols {
glyph := terminal.Cell(x, y)
attrs := getTextAttrs(glyph, cursor, x, y)

if attrs.background == vt10x.DefaultBG {
Expand Down Expand Up @@ -133,14 +131,15 @@ func renderBackgrounds(buf *bytes.Buffer, buffer [][]vt10x.Glyph, cols, rows int
}
}

func renderText(buf *bytes.Buffer, buffer [][]vt10x.Glyph, cols, rows int, charWidthPx, rowHeightPx float64, cursor *cursorPos) {
func renderText(buf *bytes.Buffer, terminal vt10x.Terminal, charWidthPx, rowHeightPx float64, cursor *cursorPos) {
buf.WriteString(`<text>`)

for y := 0; y < len(buffer); y++ {
cols, rows := terminal.Size()
for y := range rows {
// Check if the entire row has any non-space content
hasContent := false
for x := 0; x < len(buffer[y]); x++ {
glyph := buffer[y][x]
for x := range cols {
glyph := terminal.Cell(x, y)
isCursor := cursor != nil && cursor.x == x && cursor.y == y
if (glyph.Char != 0 && glyph.Char != ' ') || isCursor {
hasContent = true
Expand All @@ -156,8 +155,8 @@ func renderText(buf *bytes.Buffer, buffer [][]vt10x.Glyph, cols, rows int, charW
fmt.Fprintf(buf, `<tspan y="%.1f" dy="1em">`, yPos)

col := 0
for col < len(buffer[y]) {
glyph := buffer[y][col]
for col < cols {
glyph := terminal.Cell(col, y)
isCursor := cursor != nil && cursor.x == col && cursor.y == y

// Skip spaces completely (unless it's the cursor position)
Expand All @@ -171,8 +170,8 @@ func renderText(buf *bytes.Buffer, buffer [][]vt10x.Glyph, cols, rows int, charW
var text strings.Builder

// Collect consecutive non-space characters with same attributes
for col < len(buffer[y]) {
currentGlyph := buffer[y][col]
for col < cols {
currentGlyph := terminal.Cell(col, y)
currentIsCursor := cursor != nil && cursor.x == col && cursor.y == y

// Stop if we hit a space or null (unless cursor)
Expand Down
3 changes: 1 addition & 2 deletions lib/terminal/svg_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -253,8 +253,7 @@ func TestTerminalStateToSVG(t *testing.T) {

tt.setup(vt)

state := vt.DumpState()
svg := string(VtStateToSvg(&state))
svg := string(VtToSvg(vt))

if golden.ShouldSet() {
golden.Set(t, []byte(svg))
Expand Down
Loading