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
6 changes: 3 additions & 3 deletions app/automation/overlay.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1004,17 +1004,17 @@ fn barTrack(player: *Player, tape: *const Tape, bounds: dvui.Rect, r: dvui.Rect,
const x1 = r.x + r.w - pad;
const small_font = dvui.Font.theme(.body).larger(-2);

// The speed, top right of the capsule: a press steps it (`Player.stepRate`).
// The speed, top right of the capsule: a press steps it (`Sequencer.stepSpeed`).
var speed_buf: [8]u8 = undefined;
const speed = std.mem.print(&speed_buf, "{d}\u{00D7}", .{player.rate}) catch "";
const speed = std.mem.print(&speed_buf, "{d}\u{00D7}", .{player.seq.speed}) catch "";
const speed_w = small_font.textSize(speed).w;
const speed_r: dvui.Rect = .{ .x = x1 - speed_w, .y = r.y + 5, .w = speed_w, .h = small_font.lineHeight() };
dvui.labelNoFmt(@src(), speed, .{}, .{
.rect = .{ .x = speed_r.x - bounds.x, .y = speed_r.y - bounds.y, .w = speed_r.w + 2, .h = speed_r.h },
.padding = .{},
.margin = .{},
.font = small_font,
.color_text = .{ .color = ink.opacity(if (player.rate == 1) 0.65 else 0.95) },
.color_text = .{ .color = ink.opacity(if (player.seq.speed == 1) 0.65 else 0.95) },
});
player.transport.speed = wrs.rectToPhysical(speed_r.outsetAll(4));
const chapter = if (tape.chapterAt(now)) |i| tape.chapters[i].title else tape.title;
Expand Down
10 changes: 9 additions & 1 deletion docs/AUTOMATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,15 @@ pub fn build(s: *Script) !void {
`Script` keeps a pen (`s.t`) and moves it at an unhurried person's pace (`Script.Pace`: an 800 ms
glide, a hover before the press, a beat after each action, 12 characters a second). `pause`,
`caption(.., .{ .hold = true })` and every option's `ms` adjust it. A caption shows for as long as
it takes to read (`Script.readingMs`) unless given `ms`.
it takes to read unless given `ms`: `Script.readingMs`, 0.3 s a word of title and text at 1x
(`reading_ms_per_word`) and between 2 s (`reading_min_ms`) and 12 s (`reading_max_ms`).

**Playback speed** is `Sequencer.speed`, a plain `f32` in 0.5…4 (default 1; the transport steps
`Sequencer.speeds`: 0.5, 0.75, 1, 1.5, 2, 3, 4). It divides every demo time on the wall clock —
`playUntil` turns a frame's wall time into the demo time to advance to. A caption's `ms` is demo
time too, so it is up for `ms / speed` of the wall clock with no conversion of its own — the
captions and the demo around them speed up together. A seek asks `advance` for a demo time, which
no speed enters, so it lands exactly at any speed.

A caption that narrates what the pointer does is a **callout beside the action**, where the viewer
is looking: next to what the pointer is aimed at while it shows (or `.near`, an anchor), to its
Expand Down
25 changes: 3 additions & 22 deletions sdk/replay/Player.zig
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,6 @@ after_seek: After = .pause,
seek_target: f64 = 0,
/// Where the seek replays from, for the overlay's progress.
seek_from: f64 = 0,
/// Demo time per wall time while playing: one of `speeds`, from the transport or `setRate`.
rate: f32 = 1,

/// Real input reached the app since the tape last put it in a known state.
diverged: bool = false,
/// Wall time one displayed frame may spend on silent frames while a seek catches up (`frames`).
Expand Down Expand Up @@ -221,7 +218,7 @@ pub const Transport = struct {
/// Sent into where it rests (its tab): the bar stays there, playing or not, until that brings it
/// back (a press here, the pointer held on it in the overlay).
tucked: bool = false,
/// The speed on the track (`rate`), which a press steps through `speeds`.
/// The speed on the track (`Sequencer.speed`), which a press steps through `Sequencer.speeds`.
speed: dvui.Rect.Physical = .{},
/// The demo time the scrubber is held at while a viewer drags it; the player seeks there as
/// it moves (`frame`), and once more when it is let go.
Expand Down Expand Up @@ -283,22 +280,6 @@ pub fn wallNs(self: *const Player) i128 {
}

/// The demo time to show: the scrubber's while it is held, else the sequencer's.
/// The speeds the transport steps through.
pub const speeds = [_]f32{ 0.25, 0.5, 1, 2, 4 };

/// Play at `rate` times the tape's own pace, clamped to the slowest and fastest of `speeds`.
pub fn setRate(self: *Player, rate: f32) void {
self.rate = std.math.clamp(rate, speeds[0], speeds[speeds.len - 1]);
}

/// The next of `speeds` after the current one, round to the slowest.
pub fn stepRate(self: *Player) void {
for (speeds) |sp| {
if (sp > self.rate + 0.001) return self.setRate(sp);
}
self.setRate(speeds[0]);
}

pub fn now(self: *const Player) f64 {
if (self.transport.scrub) |t| return t;
return if (self.state == .seeking) self.seek_target else self.seq.now;
Expand Down Expand Up @@ -723,7 +704,7 @@ pub fn frame(self: *Player) void {
.playing => {
Input.holdPointer(self.seq.pointer);
self.seq.replaying = false;
_ = self.seq.advance(self.seq.now + wall_ms * self.rate, wall_ms, self.sink());
_ = self.seq.advance(self.seq.playUntil(wall_ms), wall_ms, self.sink());
if (self.seq.done() and self.seq.now >= @as(f64, @floatFromInt(self.duration()))) self.state = .ended;
},
.seeking => {
Expand Down Expand Up @@ -941,7 +922,7 @@ fn transportTakes(self: *Player, e: *dvui.Event, me: dvui.Event.Mouse) bool {
} else if (tr.play.contains(me.p)) {
self.toggle();
} else if (tr.speed.contains(me.p)) {
self.stepRate();
self.seq.stepSpeed();
} else if (tr.track.contains(me.p)) {
tr.scrub = tr.timeAt(me.p.x, self.duration());
self.scrub_after = if (self.state == .playing or (self.state == .seeking and self.after_seek == .play)) .play else .pause;
Expand Down
44 changes: 39 additions & 5 deletions sdk/tape/Script.zig
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ pub const CaptionOptions = struct {

/// Words over the app, starting now.
pub fn caption(self: *Script, text: []const u8, opts: CaptionOptions) !void {
const ms = opts.ms orelse readingMs(opts.title.len + text.len);
const ms = opts.ms orelse readingMs(opts.title, text);
try self.captions.append(self.arena.allocator(), .{
.at = self.t,
.ms = ms,
Expand All @@ -181,10 +181,27 @@ pub fn caption(self: *Script, text: []const u8, opts: CaptionOptions) !void {
if (opts.hold) self.t += ms;
}

/// Long enough to read at an unhurried pace and still look at the app, never shorter than a
/// glance: about fourteen characters a second, after a couple of seconds to find it.
pub fn readingMs(chars: usize) u32 {
return std.math.clamp(@as(u32, @intCast(chars)) * 70 + 1800, 3200, 12_000);
/// Demo time a caption shows for per word at 1x (`readingMs`).
pub const reading_ms_per_word: u32 = 300;
/// The least a caption shows for at 1x, however short: long enough to find and take in.
pub const reading_min_ms: u32 = 2000;
/// The most a caption shows for at 1x, however long: past this a caption should be split.
pub const reading_max_ms: u32 = 12_000;

/// The words in `text`: runs of non-whitespace.
pub fn wordCount(text: []const u8) usize {
var n: usize = 0;
var it = std.mem.tokenizeAny(u8, text, " \t\r\n");
while (it.next()) |_| n += 1;
return n;
}

/// How long a caption shows for at 1x: `reading_ms_per_word` for each word of its title and text,
/// between `reading_min_ms` and `reading_max_ms`. It is demo time, so playback speed (which
/// advances demo time faster) shortens the caption and the demo around it together.
pub fn readingMs(title: []const u8, text: []const u8) u32 {
const words: u32 = @intCast(wordCount(title) + wordCount(text));
return std.math.clamp(words * reading_ms_per_word, reading_min_ms, reading_max_ms);
}

/// Nothing for a while.
Expand Down Expand Up @@ -470,3 +487,20 @@ test "a stroke presses once, traces each point, and releases" {
try testing.expectEqual(2, traces);
try testing.expect(released_last);
}

test "a caption shows for a time per word, never under the floor" {
try testing.expectEqual(@as(usize, 0), wordCount(""));
try testing.expectEqual(@as(usize, 0), wordCount(" \n\t "));
try testing.expectEqual(@as(usize, 1), wordCount("Hi"));
try testing.expectEqual(@as(usize, 4), wordCount(" one two\tthree\nfour "));
// Under the floor: a glance is still 2 s.
try testing.expectEqual(reading_min_ms, readingMs("", "Hi"));
try testing.expectEqual(reading_min_ms, readingMs("", ""));
// Six words is exactly the floor; the seventh is past it.
try testing.expectEqual(@as(u32, 2000), readingMs("", "a b c d e f"));
try testing.expectEqual(@as(u32, 2100), readingMs("", "a b c d e f g"));
// Title and text both count, and a long caption is not cut short.
try testing.expectEqual(@as(u32, 300 * 30), readingMs("one two three four five", "a b c d e f g h i j k l m n o p q r s t u v w x y"));
// Past the ceiling a caption stops growing.
try testing.expectEqual(reading_max_ms, readingMs("", "w w w w w w w w w w w w w w w w w w w w w w w w w w w w w w w w w w w w w w w w w w"));
}
88 changes: 88 additions & 0 deletions sdk/tape/Sequencer.zig
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,16 @@ pressed: u8 = 0,
/// Set by whoever plays the tape while it replays to a moment rather than plays live (a seek):
/// a carried glide then takes a frame per step of its path (`path_step_ms`).
replaying: bool = false,
/// Demo time per wall time while a tape plays live: a plain value in `min_speed`...`max_speed`
/// (`setSpeed`). Only `playUntil` reads it, so a seek, which replays to a demo time,
/// lands exactly at any speed.
speed: f32 = default_speed,

pub const min_speed: f32 = 0.5;
pub const max_speed: f32 = 4.0;
pub const default_speed: f32 = 1.0;
/// The speeds a transport steps through.
pub const speeds = [_]f32{ 0.5, 0.75, 1, 1.5, 2, 3, 4 };

pub const Point = struct {
x: f32 = 0,
Expand Down Expand Up @@ -156,6 +166,24 @@ pub fn restoreTo(self: *Sequencer, cursor: usize, now: f64, pointer: Point) void
self.pressed = 0;
}

/// Play at `speed` times the tape's own pace, kept within `min_speed`...`max_speed`.
pub fn setSpeed(self: *Sequencer, speed: f32) void {
self.speed = std.math.clamp(speed, min_speed, max_speed);
}

/// The next of `speeds` above the current one, round to the slowest.
pub fn stepSpeed(self: *Sequencer) void {
for (speeds) |sp| {
if (sp > self.speed + 0.001) return self.setSpeed(sp);
}
self.setSpeed(speeds[0]);
}

/// The demo time `wall_ms` of live playing reaches: what to ask `advance` for.
pub fn playUntil(self: Sequencer, wall_ms: f64) f64 {
return self.now + wall_ms * self.speed;
}

/// Every op has been applied and nothing is in flight.
pub fn done(self: Sequencer) bool {
return self.cursor >= self.tape.ops.len and self.glide == null and self.typing == null and !self.holding;
Expand Down Expand Up @@ -913,3 +941,63 @@ test "a trace runs ruled: on the line between, evenly" {
try testing.expectEqual(@as(f32, 0), pathPoint(op, a, b, 0.5).y);
try testing.expectEqual(@as(f32, 100), pathPoint(op, a, b, 0.25).x);
}

test "speed is clamped, steps through its notches and divides demo time" {
const tape: Tape = .{ .name = "t", .ops = &.{} };
var seq = Sequencer.init(&tape);
try testing.expectEqual(@as(f32, 1), seq.speed);
seq.setSpeed(0.1);
try testing.expectEqual(min_speed, seq.speed);
seq.setSpeed(40);
try testing.expectEqual(max_speed, seq.speed);

// Every notch: a frame of wall time reaches `speed` times as much demo time.
for (speeds) |sp| {
seq.setSpeed(sp);
try testing.expectEqual(sp, seq.speed);
seq.now = 100;
try testing.expectEqual(100 + 16 * @as(f64, sp), seq.playUntil(16));
}

// The transport steps through them in order and wraps to the slowest.
seq.setSpeed(min_speed);
for (speeds[1..]) |sp| {
seq.stepSpeed();
try testing.expectEqual(sp, seq.speed);
}
seq.stepSpeed();
try testing.expectEqual(speeds[0], seq.speed);
}

test "a rewind and seek land on the same moment at every speed" {
const ops = [_]Tape.Op{
.{ .at = 0, .do = .{ .keyframe = 0 } },
.{ .at = 0, .ms = 100, .do = .{ .move = .{ .tag = "ok" } } },
.{ .at = 100, .do = .{ .press = .left } },
.{ .at = 150, .do = .{ .release = .left } },
};
const tape: Tape = .{ .name = "t", .ops = &ops, .keyframes = &test_keyframes };

var expected: ?[]u8 = null;
defer if (expected) |e| testing.allocator.free(e);
for (speeds) |sp| {
var log: Log = .{};
defer log.deinit();
try log.tags.put(testing.allocator, "ok", .{ .x = 100, .y = 0 });

var seq: Sequencer = .init(&tape);
seq.setSpeed(sp);
// Play on a way, then go back and replay to a moment, as a seek does.
_ = seq.advance(seq.playUntil(40), 40, log.sink());
seq.rewind(0);
log.out.clearRetainingCapacity();
seq.replaying = true;
while (seq.advance(120, 0, log.sink()) != .reached) {}
try testing.expectEqual(@as(f64, 120), seq.now);
const landed = try landings(log.out.items);
if (expected) |e| {
defer testing.allocator.free(landed);
try testing.expectEqualStrings(e, landed);
} else expected = landed;
}
}
2 changes: 1 addition & 1 deletion src/editor/Editor.zig
Original file line number Diff line number Diff line change
Expand Up @@ -4379,7 +4379,7 @@ pub fn tick(editor: *Editor) !dvui.App.Result {
// How things move this frame, for every animation here and in every plugin (`core.motion`).
// A demo replaying to a seek wants every frame to land where it is going: no motion — but the
// glass keeps its edge, the transport's among it, scrubbed or not.
const playback: f32 = if (editor.demo.player.state == .playing) editor.demo.player.rate else 1;
const playback: f32 = if (editor.demo.player.state == .playing) editor.demo.player.seq.speed else 1;
fizzy.core.motion.publish(editor.app.settings.motion, editor.app.settings.motion_speed, dvui.currentWindow().backend.prefersReducedMotion(), editor.demo.fast, playback);
fizzy.core.programs.publishHost();
// Where the main window's title bar takes presses, not the app (macOS): floating windows,
Expand Down
8 changes: 4 additions & 4 deletions src/editor/Keybinds.zig
Original file line number Diff line number Diff line change
Expand Up @@ -300,18 +300,18 @@ fn setWindowMaterial(editor: *Editor, kind: fizzy.core.material.Kind) void {
}

const DemoSpeed = sdk.Command.Params(struct {
rate: sdk.Command.Arg(f32, .{ .description = "How many times the tape's own pace: 0.25, 0.5, 1, 2 or 4. 0 steps to the next.", .min = 0, .max = 4 }) = .init(0),
rate: sdk.Command.Arg(f32, .{ .description = "How many times the tape's own pace: 0.5 to 4 (0.5, 0.75, 1, 1.5, 2, 3, 4 on the transport); anything between 0 and 0.5 plays at 0.5. 0 steps to the next.", .min = 0, .max = 4 }) = .init(0),
});

fn cmdDemoSpeedStep(state: *anyopaque) anyerror!void {
editorFromState(state).demo.player.stepRate();
editorFromState(state).demo.player.seq.stepSpeed();
}

/// How fast the loaded tape plays, as the transport's speed does.
fn cmdDemoSpeed(state: *anyopaque, args: DemoSpeed.Args, call: *sdk.Command.Call) anyerror!void {
const player = &editorFromState(state).demo.player;
if (args.rate == 0) player.stepRate() else player.setRate(args.rate);
try call.returns(.{ .rate = player.rate });
if (args.rate == 0) player.seq.stepSpeed() else player.seq.setSpeed(args.rate);
try call.returns(.{ .rate = player.seq.speed });
}

fn demoCatalogCommands() [Editor.Demo.catalog.entries.len]FizzyCommand {
Expand Down
Loading