diff --git a/src/VcrSharp.Core/Session/SessionOptions.cs b/src/VcrSharp.Core/Session/SessionOptions.cs index 93efd6e..af35c78 100644 --- a/src/VcrSharp.Core/Session/SessionOptions.cs +++ b/src/VcrSharp.Core/Session/SessionOptions.cs @@ -50,6 +50,15 @@ public class SessionOptions /// public bool FitToContent { get; set; } + /// + /// Gets or sets whether fit-to-content crops only the height, keeping the full grid + /// width (Cols × cell width). Set via Set Size "fit-height". Use this to keep a + /// uniform width across a batch of screenshots — so apparent font size stays constant + /// when they are scaled to a fixed display width — while still trimming trailing blank + /// rows. Implies . Defaults to false. + /// + public bool FitHeightOnly { get; set; } + // Font Settings /// @@ -625,9 +634,14 @@ private static void ApplySetting(SessionOptions options, string name, object val case "mode": // capture mode: "animated" (default, capture frames) or "static" (one settled frame) options.StaticOutput = string.Equals(value.ToString(), "static", StringComparison.OrdinalIgnoreCase); break; - case "size": // canvas sizing: "grid" (default, exact Cols×Rows) or "fit" (crop to content + scale) - options.FitToContent = string.Equals(value.ToString(), "fit", StringComparison.OrdinalIgnoreCase); + case "size": // canvas sizing: "grid" (viewport), "fit" (crop to content), "fit-height" (keep Cols width, crop height) + { + var size = (value.ToString() ?? string.Empty).Replace("-", string.Empty).Replace("_", string.Empty).Trim(); + options.FitHeightOnly = size.Equals("fitheight", StringComparison.OrdinalIgnoreCase) + || size.Equals("fitrows", StringComparison.OrdinalIgnoreCase); + options.FitToContent = size.StartsWith("fit", StringComparison.OrdinalIgnoreCase); break; + } } } diff --git a/src/VcrSharp.Core/Settings/SettingDeprecations.cs b/src/VcrSharp.Core/Settings/SettingDeprecations.cs index f4f43ca..992d242 100644 --- a/src/VcrSharp.Core/Settings/SettingDeprecations.cs +++ b/src/VcrSharp.Core/Settings/SettingDeprecations.cs @@ -33,9 +33,20 @@ public static class SettingDeprecations new Dictionary(StringComparer.OrdinalIgnoreCase) { ["Mode"] = new[] { "animated", "static" }, - ["Size"] = new[] { "grid", "fit" }, + // Size accepts grid | fit | fit-height (alias fit-rows). Separators are normalized away + // before comparison (see NormalizeEnumValue), mirroring SessionOptions.ApplySetting, so + // "fit-height", "fit_height" and "fitheight" all validate. + ["Size"] = new[] { "grid", "fit", "fit-height", "fit-rows" }, }; + /// + /// Strips separators and case so enum-value comparison matches how SessionOptions.ApplySetting + /// reads the value (e.g. Set Size "fit-height"fitheight). Keeps the typo lint from + /// false-flagging a spelling the engine actually honors. + /// + private static string NormalizeEnumValue(string? value) => + (value ?? string.Empty).Replace("-", string.Empty).Replace("_", string.Empty).Trim(); + /// /// Collects deprecation warnings (and Mode/Size typo warnings) for the commands a user authored. /// Pass the parsed tape commands (before preset resolution) so line numbers point at the tape. @@ -55,7 +66,8 @@ public static List Collect(IEnumerable commands) warnings.Add($"{where}'Set {set.SettingName}' is deprecated — {settingMessage}."); } else if (EnumSettings.TryGetValue(set.SettingName, out var allowed) - && !allowed.Contains(set.Value.ToString(), StringComparer.OrdinalIgnoreCase)) + && !allowed.Any(a => NormalizeEnumValue(a) + .Equals(NormalizeEnumValue(set.Value.ToString()), StringComparison.OrdinalIgnoreCase))) { warnings.Add($"{where}'Set {set.SettingName} {set.Value}' is not recognized — use {string.Join(" or ", allowed)}."); } diff --git a/src/VcrSharp.Infrastructure/Rendering/SvgRenderer.cs b/src/VcrSharp.Infrastructure/Rendering/SvgRenderer.cs index b998518..213686f 100644 --- a/src/VcrSharp.Infrastructure/Rendering/SvgRenderer.cs +++ b/src/VcrSharp.Infrastructure/Rendering/SvgRenderer.cs @@ -1026,6 +1026,11 @@ private void CalculateDimensions() /// public void SetContentExtent(int cols, int rows) { + // Fit-height mode keeps the full grid width (Cols) and crops only the height, so a + // batch of screenshots shares one width — and thus one apparent font size when scaled + // to a fixed display width. Grow, never shrink, so content wider than Cols isn't clipped. + if (_options.FitHeightOnly && _options.Cols.HasValue) + cols = Math.Max(cols, _options.Cols.Value); _cropCols = Math.Max(cols, 1); _cropRows = Math.Max(rows, 1); CalculateDimensions(); diff --git a/src/VcrSharp.Infrastructure/Rendering/SvgWriter.cs b/src/VcrSharp.Infrastructure/Rendering/SvgWriter.cs index 221c193..0063209 100644 --- a/src/VcrSharp.Infrastructure/Rendering/SvgWriter.cs +++ b/src/VcrSharp.Infrastructure/Rendering/SvgWriter.cs @@ -51,9 +51,19 @@ public static async Task WriteAnimatedAsync(IReadOnlyList /// Down-samples a timestamp-ordered state stream to at most frames per second - /// by keeping the first state in each 1/fps window and skipping everything until the window elapses. - /// The final state is always kept so the settled end-of-recording output is never dropped. Returns - /// the input unchanged when there is nothing to thin (fps <= 0 or two-or-fewer states). + /// by keeping the LAST state in each 1/fps window (plus the first and final states). Returns the + /// input unchanged when there is nothing to thin (fps <= 0 or two-or-fewer states). + /// + /// Keeping the last — not the first — state in each window is what makes this safe for the + /// event-driven capture stream. A screen redraw (a table scrolling in, a TUI repaint) is captured + /// as a short-lived torn intermediate frame immediately followed by the settled frame, often only + /// ~10–15 ms apart. Both fall inside one 1/fps window. Keeping the first would freeze the + /// torn frame for the whole window (~one display slot, e.g. a 2 s plateau) — visible corruption + /// where rows from two different screens overlap. Keeping the last drops the transient and shows the + /// settled screen, which is also what the un-quantized raster/GIF path effectively displays (the tear + /// only ever flashes for its true sub-frame duration there). The final settled frame is always kept so + /// end-of-recording output survives. + /// /// internal static List QuantizeToFramerate(List states, int fps) { @@ -61,16 +71,22 @@ internal static List QuantizeToFramerate(List(states.Count); - var lastKept = double.NegativeInfinity; + + static long Window(double ts, double minInterval) => (long)(ts / minInterval); for (var i = 0; i < states.Count; i++) { + // Keep the first state (so t=0 already shows content) and the last state (settled end). + // Otherwise keep a state only when it is the last one in its frame window — i.e. the next + // state belongs to a later window. A state superseded within the same window is a transient + // mid-redraw frame and is skipped in favor of the settled state it resolves into. + var isFirst = i == 0; var isLast = i == states.Count - 1; - if (isLast || states[i].TimestampSeconds - lastKept >= minInterval) - { + var lastInWindow = isLast || + Window(states[i + 1].TimestampSeconds, minInterval) > Window(states[i].TimestampSeconds, minInterval); + + if (isFirst || lastInWindow) result.Add(states[i]); - lastKept = states[i].TimestampSeconds; - } } return result; diff --git a/tests/VcrSharp.Core.Tests/Rendering/SvgQuantizeTests.cs b/tests/VcrSharp.Core.Tests/Rendering/SvgQuantizeTests.cs new file mode 100644 index 0000000..53ffc48 --- /dev/null +++ b/tests/VcrSharp.Core.Tests/Rendering/SvgQuantizeTests.cs @@ -0,0 +1,88 @@ +using Shouldly; +using VcrSharp.Core.Rendering; +using VcrSharp.Infrastructure.Rendering; + +namespace VcrSharp.Core.Tests.Rendering; + +/// +/// Tests for , the framerate down-sampler for animated SVG. +/// The critical contract is that when a settled frame is preceded by a short-lived transient inside the +/// same frame window (the event-driven capture's mid-redraw tear), the SETTLED frame is the one kept — +/// otherwise the torn intermediate is frozen for the whole display slot, producing visible corruption. +/// +public class SvgQuantizeTests +{ + private static TerminalStateWithTime State(double ts, string tag = "x") + { + // Content identity doesn't matter for quantization (it keys purely on timestamps); the tag just + // lets a test assert which frame survived. + var cells = new[] { new[] { new TerminalCell { Character = tag, Width = 1 } } }; + return new TerminalStateWithTime + { + Content = new TerminalContent { Cols = 1, Rows = 1, Cells = cells }, + TimestampSeconds = ts, + }; + } + + private static string Tag(TerminalStateWithTime s) => s.Content.Cells[0][0].Character!; + + [Fact] + public void TransientThenSettled_InSameWindow_KeepsSettled() + { + // Mirrors the real capture stream: each ~2 s plateau is a short transient tear immediately + // followed (here ~14 ms later, inside one 1/50 s = 20 ms window) by the settled frame. + const int fps = 50; + var states = new List + { + State(0.000, "settled0"), + State(2.000, "torn1"), State(2.014, "settled1"), + State(4.000, "torn2"), State(4.013, "settled2"), + State(6.000, "torn3"), State(6.015, "settled3"), + }; + + var kept = SvgWriter.QuantizeToFramerate(states, fps).Select(Tag).ToList(); + + // The torn intermediates are dropped; the settled frame of each plateau survives. + kept.ShouldBe(new[] { "settled0", "settled1", "settled2", "settled3" }); + } + + [Fact] + public void FinalSettledFrame_IsAlwaysKept() + { + // The end-of-recording settled frame must survive even when it trails a transient within a window. + var states = new List + { + State(0.000, "a"), + State(5.000, "torn"), State(5.012, "final"), + }; + + SvgWriter.QuantizeToFramerate(states, 50).Select(Tag).Last().ShouldBe("final"); + } + + [Fact] + public void ContinuousFastStream_IsDownsampledTowardFramerate() + { + // A progress-bar-style emitter that changes every ~16 ms over 2 s should be thinned to roughly + // the target framerate (one frame per 1/fps window), not collapsed and not left untouched. + const int fps = 25; // 40 ms window + var states = new List(); + for (var i = 0; i <= 120; i++) // 0..1.92 s in 16 ms steps => 121 states + states.Add(State(i * 0.016, $"f{i}")); + + var kept = SvgWriter.QuantizeToFramerate(states, fps); + + // ~2 s at 25 fps ≈ 50 frames; allow slack but it must be a real reduction from 121 and not a collapse. + kept.Count.ShouldBeGreaterThan(30); + kept.Count.ShouldBeLessThan(70); + // Timestamps stay strictly increasing and the endpoints are preserved. + Tag(kept[0]).ShouldBe("f0"); + Tag(kept[^1]).ShouldBe("f120"); + } + + [Fact] + public void TwoOrFewerStates_ReturnedUnchanged() + { + var states = new List { State(0.0, "a"), State(1.0, "b") }; + SvgWriter.QuantizeToFramerate(states, 50).ShouldBeSameAs(states); + } +} diff --git a/tests/VcrSharp.Core.Tests/Settings/SettingDeprecationsTests.cs b/tests/VcrSharp.Core.Tests/Settings/SettingDeprecationsTests.cs index 02e8409..454bfd4 100644 --- a/tests/VcrSharp.Core.Tests/Settings/SettingDeprecationsTests.cs +++ b/tests/VcrSharp.Core.Tests/Settings/SettingDeprecationsTests.cs @@ -82,6 +82,16 @@ public void Collect_FlagsModeAndSizeTypos() .ShouldContain(w => w.Contains("Size") && w.Contains("grid or fit")); } + [Fact] + public void Collect_DoesNotFlagFitHeightSizeVariants() + { + // fit-height (and its separator/case variants and the fit-rows alias) are honored by + // ApplySetting, so the typo lint must not flag them. + foreach (var value in new[] { "fit-height", "fit_height", "fitheight", "fit-rows" }) + SettingDeprecations.Collect(Parser.ParseTape($"Set Size \"{value}\"\nExec \"x\"")) + .ShouldNotContain(w => w.Contains("not recognized"), $"Size {value} should be accepted"); + } + [Fact] public void Collect_CleanModernTape_NoWarnings() {