diff --git a/pkg/gif/measure.go b/pkg/gif/measure.go new file mode 100644 index 0000000..4e7f501 --- /dev/null +++ b/pkg/gif/measure.go @@ -0,0 +1,91 @@ +package gif + +import ( + "context" + "fmt" + "image/gif" + "os" + "path/filepath" +) + +// probe cast column counts. The per-column width is isolated by the slope +// (wideCols - narrowCols), which cancels agg's fixed horizontal padding. +const ( + probeNarrowCols = 40 + probeWideCols = 120 + probeRows = 8 +) + +// MeasureColumnWidthPx renders two short probe casts that differ only in column +// count and returns the pixel width agg actually advances per column for the +// given font configuration. agg lays columns out at a fixed advance plus fixed +// horizontal padding, so (wideWidth - narrowWidth) / (wideCols - narrowCols) +// yields the per-column width independent of that padding — and independent of +// which font agg resolves on the host, which a static formula cannot know. +func MeasureColumnWidthPx(ctx context.Context, config Config) (float64, error) { + config = probeConfig(NormalizeConfig(config)) + + dir, err := os.MkdirTemp("", "tuirec-probe-*") + if err != nil { + return 0, fmt.Errorf("probe tempdir: %w", err) + } + defer os.RemoveAll(dir) + + narrow, err := renderProbeWidth(ctx, config, dir, probeNarrowCols) + if err != nil { + return 0, err + } + + wide, err := renderProbeWidth(ctx, config, dir, probeWideCols) + if err != nil { + return 0, err + } + + span := wide - narrow + if span <= 0 { + return 0, fmt.Errorf("probe produced non-increasing widths (%d, %d)", narrow, wide) + } + + return float64(span) / float64(probeWideCols-probeNarrowCols), nil +} + +// probeConfig strips timeline-only options that would distort or empty the +// synthetic probe render. The cell geometry agg renders at is independent of +// the timeline, but a Select range (e.g. "1..") can trim away the probe cast's +// only events at 0.1s/0.2s, leaving agg an empty selection and breaking +// calibration. Font and spacing options that DO affect column width +// (FontSize, LineHeight, Font, LetterSpacing) are preserved. +func probeConfig(config Config) Config { + config.Select = "" + + return config +} + +// renderProbeWidth writes a minimal cast of the given column count, renders it +// with agg, and returns the resulting GIF's pixel width. +func renderProbeWidth(ctx context.Context, config Config, dir string, cols int) (int, error) { + castPath := filepath.Join(dir, fmt.Sprintf("probe-%d.cast", cols)) + gifPath := filepath.Join(dir, fmt.Sprintf("probe-%d.gif", cols)) + + cast := fmt.Sprintf("{\"version\":2,\"width\":%d,\"height\":%d}\n[0.1,\"o\",\"x\"]\n[0.2,\"o\",\"y\"]\n", cols, probeRows) + if err := os.WriteFile(castPath, []byte(cast), 0o600); err != nil { + return 0, fmt.Errorf("write probe cast: %w", err) + } + + if err := Render(ctx, castPath, gifPath, config); err != nil { + return 0, fmt.Errorf("render probe: %w", err) + } + + file, err := os.Open(gifPath) + if err != nil { + return 0, fmt.Errorf("open probe gif: %w", err) + } + defer file.Close() + + cfg, err := gif.DecodeConfig(file) + if err != nil { + return 0, fmt.Errorf("decode probe gif: %w", err) + } + + return cfg.Width, nil +} diff --git a/pkg/gif/measure_test.go b/pkg/gif/measure_test.go new file mode 100644 index 0000000..0f9ee0c --- /dev/null +++ b/pkg/gif/measure_test.go @@ -0,0 +1,35 @@ +package gif + +import "testing" + +// A Select range that trims past the probe cast's only events (0.1s/0.2s) would +// leave agg an empty selection and silently break calibration. probeConfig must +// clear it while preserving every option that affects column width. +func TestProbeConfigClearsSelectKeepsGeometry(t *testing.T) { + in := Config{ + AggPath: "agg", + Theme: "nord", + Speed: 2, + Font: "JetBrains Mono", + FontSize: 15, + LineHeight: 1.3, + LetterSpacing: 0.5, + Select: "1..", + } + + got := probeConfig(in) + + if got.Select != "" { + t.Fatalf("Select not cleared: %q", got.Select) + } + + // Geometry- and render-affecting fields must survive untouched. + if got.FontSize != in.FontSize || + got.LineHeight != in.LineHeight || + got.Font != in.Font || + got.LetterSpacing != in.LetterSpacing || + got.Theme != in.Theme || + got.AggPath != in.AggPath { + t.Fatalf("probeConfig altered a non-timeline field: got %+v", got) + } +} diff --git a/pkg/record/calibrate.go b/pkg/record/calibrate.go new file mode 100644 index 0000000..d9e4e48 --- /dev/null +++ b/pkg/record/calibrate.go @@ -0,0 +1,101 @@ +package record + +import ( + "context" + "math" + + "github.com/gui-cs/tuirec/pkg/gif" +) + +// alignTolerancePx is the largest per-column rounding error tolerated before the +// font-size is nudged to align the sixel cell grid. agg lays columns out at a +// fractional advance (≈0.6 × font-size), but terminal cell-size reports (CSI +// 16t) are integers, so an app that sizes a sixel raster as cells × reportedCell +// underfills by (advance − round(advance)) per column. At ~0.15px that residual +// is well under a third of a cell across a full-width image; beyond it the gap +// becomes visible (see gui-cs/tuirec#84). +const alignTolerancePx = 0.15 + +// cellRoundingErr is how far agg's per-column advance at fontSize is from the +// nearest integer — i.e. the per-column error if that integer is reported. +func cellRoundingErr(ratio float64, fontSize int) float64 { + w := ratio * float64(fontSize) + + return math.Abs(w - math.Round(w)) +} + +// alignFontSize picks the font-size whose agg cell width rounds closest to an +// integer, so the reported (integer) cell matches what agg renders. The +// requested size is kept when its rounding error is already within tolerance; +// otherwise nearby sizes (±1 then ±2) are searched and the best is chosen, +// preferring the smallest move on ties. ratio is agg's measured advance per +// font-size pixel. Returns the chosen size and the integer cell width to report. +func alignFontSize(ratio float64, requested int) (fontSize, cellW int) { + if requested < 1 { + requested = 1 + } + + best := requested + bestErr := cellRoundingErr(ratio, requested) + + if bestErr > alignTolerancePx { + for _, delta := range []int{-1, 1, -2, 2} { + cand := requested + delta + if cand < 1 { + continue + } + + if e := cellRoundingErr(ratio, cand); e < bestErr-1e-9 { + best = cand + bestErr = e + } + } + } + + cellW = int(math.Round(ratio * float64(best))) + if cellW < 1 { + cellW = 1 + } + + return best, cellW +} + +// alignLineHeight returns the integer cell height to report and the line-height +// to pass to agg so that agg renders exactly that height. agg's row height is +// fontSize × lineHeight, so reporting round(fontSize × requested) and rendering +// with lineHeight = cellH / fontSize makes the report exact. +func alignLineHeight(fontSize int, requestedLineHeight float64) (cellH int, aggLineHeight float64) { + cellH = int(math.Round(float64(fontSize) * requestedLineHeight)) + if cellH < 1 { + cellH = 1 + } + + return cellH, float64(cellH) / float64(fontSize) +} + +// calibrateGeometry measures agg's actual per-column advance for the configured +// font and returns font settings whose rendered cell is an integer, plus that +// integer cell size to report to sixel queries. This replaces a static +// advance-ratio guess (which is wrong for whatever font agg resolves on the +// host) with a measurement, and aligns the grid so apps that size rasters as +// cells × reportedCell fill exactly (gui-cs/tuirec#84). The returned Config has +// FontSize/LineHeight adjusted for rendering; changed reports whether the +// font-size moved from the request. +func calibrateGeometry(ctx context.Context, gifCfg gif.Config) (adjusted gif.Config, cellW, cellH int, changed bool, err error) { + cfg := gif.NormalizeConfig(gifCfg) + + colPx, err := gif.MeasureColumnWidthPx(ctx, cfg) + if err != nil { + return gifCfg, 0, 0, false, err + } + + ratio := colPx / float64(cfg.FontSize) + fontSize, cellW := alignFontSize(ratio, cfg.FontSize) + cellH, aggLineHeight := alignLineHeight(fontSize, cfg.LineHeight) + + changed = fontSize != cfg.FontSize + cfg.FontSize = fontSize + cfg.LineHeight = aggLineHeight + + return cfg, cellW, cellH, changed, nil +} diff --git a/pkg/record/calibrate_test.go b/pkg/record/calibrate_test.go new file mode 100644 index 0000000..5f7fa24 --- /dev/null +++ b/pkg/record/calibrate_test.go @@ -0,0 +1,88 @@ +package record + +import ( + "math" + "testing" + + "github.com/gui-cs/tuirec/pkg/gif" +) + +func TestAlignFontSize(t *testing.T) { + // DejaVu Sans Mono advance ratio measured from agg (8.425px / 14px). + const ratio = 0.601786 + + tests := []struct { + name string + ratio float64 + requested int + wantSize int + wantCellW int + }{ + // 14 → cell 8.425, rounding error 0.425 > tolerance, so nudge to 15 + // (cell 9.027, error 0.027). + {"poorly aligned nudges up", ratio, 14, 15, 9}, + // 20 → cell 12.036, error 0.036 < tolerance, keep as requested. + {"well aligned kept", ratio, 20, 20, 12}, + // 15 → cell 9.027, error 0.027 < tolerance, keep. + {"already aligned kept", ratio, 15, 15, 9}, + // Exact integer ratio: every size is aligned, keep requested. + {"exact ratio kept", 0.5, 14, 14, 7}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotSize, gotCellW := alignFontSize(tt.ratio, tt.requested) + if gotSize != tt.wantSize || gotCellW != tt.wantCellW { + t.Fatalf("alignFontSize(%.5f, %d) = (size %d, cellW %d); want (size %d, cellW %d)", + tt.ratio, tt.requested, gotSize, gotCellW, tt.wantSize, tt.wantCellW) + } + }) + } +} + +func TestAlignFontSizeNeverBelowOne(t *testing.T) { + size, cellW := alignFontSize(0.6, 1) + if size < 1 || cellW < 1 { + t.Fatalf("alignFontSize(0.6, 1) = (%d, %d); want both >= 1", size, cellW) + } +} + +func TestAlignLineHeightRendersExactInteger(t *testing.T) { + // Reporting cellH and rendering with the returned line-height must make + // agg's row height (fontSize × lineHeight) equal cellH exactly. + cellH, lh := alignLineHeight(15, 1.3) // round(19.5) = 20 + if cellH != 20 { + t.Fatalf("cellH = %d; want 20", cellH) + } + + rendered := 15 * lh + if math.Abs(rendered-float64(cellH)) > 1e-9 { + t.Fatalf("agg row height %.6f != reported cellH %d", rendered, cellH) + } +} + +func TestWillRenderGIF(t *testing.T) { + // A GIF is rendered whenever Output is set; calibration must run there even + // when AggPath is the zero value (normalized to "agg" downstream). + if !willRenderGIF(Config{Output: "out.gif"}) { + t.Fatal("Output set (empty AggPath) should render and calibrate") + } + + if !willRenderGIF(Config{Output: "out.gif", GIF: gif.Config{AggPath: "agg"}}) { + t.Fatal("Output set with explicit AggPath should render and calibrate") + } + + if willRenderGIF(Config{}) { + t.Fatal("no Output should not render") + } +} + +func TestCellRoundingErr(t *testing.T) { + if e := cellRoundingErr(0.6, 10); e > 1e-9 { // 6.0 exactly + t.Fatalf("cellRoundingErr(0.6, 10) = %.6f; want 0", e) + } + + if e := cellRoundingErr(0.601786, 14); math.Abs(e-0.425) > 0.01 { // 8.425 + t.Fatalf("cellRoundingErr(0.601786, 14) = %.6f; want ~0.425", e) + } +} diff --git a/pkg/record/record.go b/pkg/record/record.go index d91d31a..af12f00 100644 --- a/pkg/record/record.go +++ b/pkg/record/record.go @@ -83,8 +83,13 @@ type Result struct { // queries. The size and font config are normalized with the same defaults // pty.Start and gif.Render apply, so a zero-valued Config still reports the // dimensions the PTY and agg actually use instead of zeros (which would break -// app layout or yield a 0x0 raster). The cell size mirrors agg's rendering: +// app layout or yield a 0x0 raster). The cell size approximates agg's rendering: // row = fontSize*lineHeight, column ~= 0.6*fontSize for a monospace font. +// +// This is a fallback estimate. When a GIF is rendered, Run replaces it with a +// measured, grid-aligned cell via calibrateGeometry, because the 0.6 column +// ratio is wrong for whatever font agg actually resolves on the host and a +// fractional advance cannot be reported as an integer cell (gui-cs/tuirec#84). func sixelGeometry(size pty.Size, gifConfig gif.Config) (cols, rows, cellW, cellH int) { size = pty.NormalizeSize(size) gifConfig = gif.NormalizeConfig(gifConfig) @@ -94,6 +99,16 @@ func sixelGeometry(size pty.Size, gifConfig gif.Config) (cols, rows, cellW, cell return size.Cols, size.Rows, cellW, cellH } +// willRenderGIF reports whether Run will render a GIF, and therefore whether the +// sixel cell size should be calibrated against agg. It mirrors the render gate +// (`config.Output != ""`). AggPath is deliberately not checked: an empty path is +// the common zero-value API case and is normalized to "agg" by gif.Render and +// calibrateGeometry alike, so gating on it would skip calibration for renders +// that still happen. +func willRenderGIF(config Config) bool { + return config.Output != "" +} + type gifRenderer struct{} type contextWriter struct { @@ -115,6 +130,11 @@ const ( sourceContext ) +// calibrationTimeout bounds the agg probe renders used to calibrate the sixel +// cell size. It is independent of the recording's MaxDuration because +// calibration is pre-recording setup, not part of the captured timeline. +const calibrationTimeout = 60 * time.Second + // Run executes one recording pipeline. func Run(parent context.Context, config Config) (Result, error) { config = normalizeConfig(config) @@ -129,6 +149,33 @@ func Run(parent context.Context, config Config) (Result, error) { return Result{}, err } + // Compute the sixel cell geometry to report and, when a GIF will be rendered, + // calibrate it against the cell agg actually renders so the integer cell + // report matches it exactly (gui-cs/tuirec#84). This runs BEFORE the + // MaxDuration context is created so the agg probe-render time is not charged + // against the recording budget — otherwise a short MaxDuration would + // spuriously trip "recording hit max duration". Falls back to the formula + // geometry if calibration fails or no GIF is produced (no agg available). + cols, rows, cellW, cellH := sixelGeometry(config.Size, config.GIF) + + if willRenderGIF(config) { + calCtx, calCancel := context.WithTimeout(parent, calibrationTimeout) + adjusted, cw, ch, changed, calErr := calibrateGeometry(calCtx, config.GIF) + calCancel() + + if calErr != nil { + logf(config, "sixel cell calibration failed (%v); using formula %dx%d px\n", calErr, cellW, cellH) + } else { + config.GIF = adjusted + cellW, cellH = cw, ch + logf(config, "sixel cell calibrated to %dx%d px (agg font-size %d, line-height %.4f)\n", cellW, cellH, adjusted.FontSize, adjusted.LineHeight) + + if changed && config.LogWriter != nil { + fmt.Fprintf(config.LogWriter, "tuirec: adjusted agg font-size to %d to align the sixel cell grid (#84)\n", adjusted.FontSize) + } + } + } + ctx, cancel := context.WithTimeout(parent, config.MaxDuration) defer cancel() @@ -174,9 +221,9 @@ func Run(parent context.Context, config Config) (Result, error) { } // Always intercept DA1/DA2 and geometry queries to advertise sixel - // capability and report the screen/cell size. This lets recorded apps - // detect sixel support, lay out their UI, and emit DCS payloads. - cols, rows, cellW, cellH := sixelGeometry(config.Size, config.GIF) + // capability and report the screen/cell size (computed/calibrated above). + // This lets recorded apps detect sixel support, lay out their UI, and emit + // DCS payloads. ptyReader = newSixelInterceptor(ptyReader, session, cols, rows, cellW, cellH) // Wrap the recorder with a synchronized writer when pointer injection is