Repository: asciinema/asciinema-player (language: python)

Issue:
Let asciinema user's code get feeded data
Hi,
I try to use asciinema for doing fun things live on Twitch. I want a web page that makes sounds and play asciinema.
I want to play some sound on `asciinema rec --stdin` events like `[42.0, "i", "\r"]`.
I tried to use the player in html page as
```
<script src="asciinema-player.min.js"></script>
<script>var asciinemaPlayer = AsciinemaPlayer.create(...)</script>
```
I think I can't get data that has been just pushed to the vt because :
1) Core's feed() function is private, and not exposed/overridable from props returned by AsciinemaPlayer.create()
2) Core's feed() function triggers terminalUpdate event without passing it data (I get the default `{}` in `e`) :
```
  feed(data) {
    this.doFeed(data);
    this.dispatchEvent('terminalUpdate');
  }
```
Do you think this could be changed without breaking things or risking future breakages (on data internal format or so) ?
Thanks for all the fish, it's very very great !
Adding Keystrokes overlay
Resolves asciinema/discussions#152 - Keystrokes overlay in subtitles style seems to be working. It's missing theme support - just white on black - and better special chars display, but it's probably good enough for a first version.

I'm used to coding without `;` and with 4 spaces for tab so there could be a few places where the source is off. I tried running eslint but got nothing meaningful out of it. I would suggest changing the test script so it'll run the linter.




Discussion / hints:
I've tried to add data arg to the event and rebuild/bundle myself locally, and I see now that is only "o" values.
terminalUpdate events seems inappropriate to catch `[42.0, "i", "\r"]` because input and terminalUpdate aren't related that much.
I've done a dirty usage of this for keypress background noise here : https://ludolpif.fr/waiting-screen/github.html
I think now that an event about the next scheduled event "i" or "o" could be more desirable, specially for timing issues (play sound before and not after display update). May be it's too much and I should extend the asciinema module instead of asking for events. There may exist some related stuff for keypress displaying.
This is very legit use case. I've been thinking myself about adding some hook/event for input ("i") stream events so we can play some click-clack.wav or show keystroke overlay (#167).

Like you noticed, `terminalUpdate` is separate from input, and for a reason. I believe we need a proper treatment of "i" asciicast events at the core level, which could be used internally for keystroke overlay, as well as exposed through `player.addEventListener` for anyone who wants to annoy people with sound :)


Fix patch:
diff --git a/README.md b/README.md
index 6cb8157c..3da5848a 100644
--- a/README.md
+++ b/README.md
@@ -590,7 +590,7 @@ player.seek(15).then(() => {
 
 Adds event listener, binding handler's `this` to the player object.
 
-The `play` event is dispatched when playback starts or resumes from pause.
+`play` event is dispatched when playback starts or resumes from pause.
 
 ```javascript
 player.addEventListener('play', () => {
@@ -598,7 +598,7 @@ player.addEventListener('play', () => {
 })
 ```
 
-The `pause` event is dispatched when playback is paused.
+`pause` event is dispatched when playback is paused.
 
 ```javascript
 player.addEventListener('pause', () => {
@@ -606,7 +606,7 @@ player.addEventListener('pause', () => {
 })
 ```
 
-The `ended` event is dispatched when playback stops after reaching the end of
+`ended` event is dispatched when playback stops after reaching the end of
 the recording.
 
 ```javascript
@@ -615,6 +615,26 @@ player.addEventListener('ended', () => {
 })
 ```
 
+`input` event is dispatched for every keyboard input that was recorded.
+Callback's 1st argument is an object with `data` field, which contains
+registered input value. Usually this is ASCII character representing a key, but
+may be a control character, like `"\r"` (enter), `"\u0001"` (ctrl-a), `"\u0003"`
+(ctrl-c), etc. See [input events in asciicast file
+format](https://github.com/asciinema/asciinema/blob/develop/doc/asciicast-v2.md#supported-event-types)
+for more information.
+
+This event can be used to play keyboard typing sound or display key presses on
+the screen amongst other use cases.
+
+```javascript
+player.addEventListener('input', { data } => {
+  console.log('input!', JSON.stringify(data));
+})
+```
+
+Note: `input` events are dispatched only for asciicasts recorded with `--stdin`
+option, e.g. `asciinema rec --stdin demo.cast`.
+
 ### `dispose()`
 
 Use this function to dispose of the player, i.e. to shut it down, release all
diff --git a/src/core.js b/src/core.js
index 6c8da729..d6fabbf0 100644
--- a/src/core.js
+++ b/src/core.js
@@ -101,6 +101,7 @@ class Core {
       ['play', []],
       ['pause', []],
       ['terminalUpdate', []],
+      ['input', []],
       ['seeked', []],
       ['ended', []]
     ]);
@@ -120,6 +121,7 @@ class Core {
     this.wasm = await vt;
 
     const feed = this.feed.bind(this);
+    const onInput = data => { this.dispatchEvent('input', { data }) };
     const now = this.now.bind(this);
     const setTimeout = (f, t) => window.setTimeout(f, t / this.speed);
     const setInterval = (f, t) => window.setInterval(f, t / this.speed);
@@ -127,7 +129,7 @@ class Core {
     const setState = this.setState.bind(this);
 
     this.driver = this.driverFn(
-      { feed, reset, now, setTimeout, setInterval, setState, logger: this.logger },
+      { feed, onInput, reset, now, setTimeout, setInterval, setState, logger: this.logger },
       { cols: this.cols, rows: this.rows, idleTimeLimit: this.idleTimeLimit, startAt: this.startAt, loop: this.loop }
     );
 
diff --git a/src/driver/recording.js b/src/driver/recording.js
index 61e7efae..5d11f861 100644
--- a/src/driver/recording.js
+++ b/src/driver/recording.js
@@ -2,43 +2,45 @@ import { unparseAsciicastV2 } from '../parser/asciicast';
 import Stream from '../stream';
 
 
-function recording(src, { feed, now, setTimeout, setState, logger }, { idleTimeLimit, startAt, loop }) {
+function recording(src, { feed, onInput, now, setTimeout, setState, logger }, { idleTimeLimit, startAt, loop }) {
   let cols;
   let rows;
-  let frames;
+  let outputs;
+  let inputs;
   let duration;
   let effectiveStartAt;
-  let timeoutId;
-  let nextFrameIndex = 0;
+  let outputTimeoutId;
+  let inputTimeoutId;
+  let nextOutputIndex = 0;
+  let nextInputIndex = 0;
   let elapsedVirtualTime = 0;
   let startTime;
   let pauseElapsedTime;
   let playCount = 0;
 
   async function init() {
-    const { parser, minFrameTime, dumpFilename } = src;
-    const recording = parser(await doFetch(src));
-    cols = recording.cols;
-    rows = recording.rows;
-    idleTimeLimit = idleTimeLimit ?? recording.idleTimeLimit
-    const result = prepareFrames(recording.frames, logger, idleTimeLimit, startAt, minFrameTime);
-    frames = result.frames;
-
-    if (frames.length === 0) {
-      throw 'recording is missing events';
+    const { parser, minFrameTime, inputOffset, dumpFilename } = src;
+
+    const recording = prepare(
+      parser(await doFetch(src)),
+      logger,
+      { idleTimeLimit, startAt, minFrameTime, inputOffset }
+    );
+
+    ({ output: outputs, input: inputs, cols, rows, duration, effectiveStartAt } = recording);
+
+    if (outputs.length === 0) {
+      throw 'recording is missing output events';
     }
 
     if (dumpFilename !== undefined) {
       const link = document.createElement('a');
-      const asciicast = unparseAsciicastV2({ ...recording, frames });
+      const asciicast = unparseAsciicastV2(recording);
       link.href = URL.createObjectURL(new Blob([asciicast], { type: 'text/plain' }));
       link.download = dumpFilename;
       link.click();
     }
 
-    effectiveStartAt = result.effectiveStartAt;
-    duration = frames[frames.length - 1][0];
-
     return { cols, rows, duration };
   }
 
@@ -62,54 +64,86 @@ function recording(src, { feed, now, setTimeout, setState, logger }, { idleTimeL
     }
   }
 
-  function scheduleNextFrame() {
-    const nextFrame = frames[nextFrameIndex];
+  function delay(targetTime) {
+    let delay = (targetTime * 1000) - (now() - startTime);
+
+    if (delay < 0) {
+      delay = 0;
+    }
 
-    if (nextFrame) {
-      const t = nextFrame[0] * 1000;
-      const elapsedWallTime = now() - startTime;
-      let timeout = t - elapsedWallTime;
+    return delay;
+  }
 
-      if (timeout < 0) {
-        timeout = 0;
-      }
+  function scheduleNextOutput() {
+    const nextOutput = outputs[nextOutputIndex];
 
-      timeoutId = setTimeout(runFrame, timeout);
+    if (nextOutput) {
+      outputTimeoutId = setTimeout(runNextOutput, delay(nextOutput[0]));
     } else {
-      playCount++;
-
-      if (loop === true || (typeof loop === 'number' && playCount < loop)) {
-        nextFrameIndex = 0;
-        startTime = now();
-        feed('\x1bc'); // reset terminal
-        scheduleNextFrame();
-      } else {
-        timeoutId = null;
-        pauseElapsedTime = duration * 1000;
-        effectiveStartAt = null;
-        setState('stopped', { reason: 'ended' });
-      }
+      onEnd();
     }
   }
 
-  function runFrame() {
-    let frame = frames[nextFrameIndex];
+  function runNextOutput() {
+    let output = outputs[nextOutputIndex];
     let elapsedWallTime;
 
     do {
-      feed(frame[1]);
-      elapsedVirtualTime = frame[0] * 1000;
-      frame = frames[++nextFrameIndex];
+      feed(output[1]);
+      elapsedVirtualTime = output[0] * 1000;
+      output = outputs[++nextOutputIndex];
       elapsedWallTime = now() - startTime;
-    } while (frame && (elapsedWallTime > frame[0] * 1000));
+    } while (output && (elapsedWallTime > output[0] * 1000));
+
+    scheduleNextOutput();
+  }
+
+  function cancelNextOutput() {
+    clearTimeout(outputTimeoutId);
+    outputTimeoutId = null;
+  }
+
+  function scheduleNextInput() {
+    const nextInput = inputs[nextInputIndex];
+
+    if (nextInput) {
+      inputTimeoutId = setTimeout(runNextInput, delay(nextInput[0]));
+    }
+  }
+
+  function runNextInput() {
+    onInput(inputs[nextInputIndex++][1]);
+    scheduleNextInput();
+  }
 
-    scheduleNextFrame();
+  function cancelNextInput() {
+    clearTimeout(inputTimeoutId);
+    inputTimeoutId = null;
+  }
+
+  function onEnd() {
+    cancelNextOutput();
+    cancelNextInput();
+    playCount++;
+
+    if (loop === true || (typeof loop === 'number' && playCount < loop)) {
+      nextOutputIndex = 0;
+      nextInputIndex = 0;
+      startTime = now();
+      feed('\x1bc'); // reset terminal
+      scheduleNextOutput();
+      scheduleNextInput();
+    } else {
+      pauseElapsedTime = duration * 1000;
+      effectiveStartAt = null;
+      setState('stopped', { reason: 'ended' });
+    }
   }
 
   function play() {
-    if (timeoutId) return true;
+    if (outputTimeoutId) return true;
 
-    if (frames[nextFrameIndex] === undefined) { // ended
+    if (outputs[nextOutputIndex] === undefined) { // ended
       effectiveStartAt = 0;
     }
 
@@ -123,10 +157,10 @@ function recording(src, { feed, now, setTimeout, setState, logger }, { idleTimeL
   }
 
   function pause() {
-    if (!timeoutId) return true;
+    if (!outputTimeoutId) return true;
 
-    clearTimeout(timeoutId);
-    timeoutId = null;
+    cancelNextOutput();
+    cancelNextInput();
     pauseElapsedTime = now() - startTime;
 
     return true;
@@ -135,11 +169,12 @@ function recording(src, { feed, now, setTimeout, setState, logger }, { idleTimeL
   function resume() {
     startTime = now() - pauseElapsedTime;
     pauseElapsedTime = null;
-    scheduleNextFrame();
+    scheduleNextOutput();
+    scheduleNextInput();
   }
 
   function seek(where) {
-    const isPlaying = !!timeoutId;
+    const isPlaying = !!outputTimeoutId;
     pause();
 
     if (typeof where === 'string') {
@@ -162,16 +197,23 @@ function recording(src, { feed, now, setTimeout, setState, logger }, { idleTimeL
 
     if (targetTime < elapsedVirtualTime) {
       feed('\x1bc'); // reset terminal
-      nextFrameIndex = 0;
+      nextOutputIndex = 0;
+      nextInputIndex = 0;
       elapsedVirtualTime = 0;
     }
 
-    let frame = frames[nextFrameIndex];
+    let output = outputs[nextOutputIndex];
+
+    while (output && (output[0] * 1000 < targetTime)) {
+      feed(output[1]);
+      elapsedVirtualTime = output[0] * 1000;
+      output = outputs[++nextOutputIndex];
+    }
+
+    let input = inputs[nextInputIndex];
 
-    while (frame && (frame[0] * 1000 < targetTime)) {
-      feed(frame[1]);
-      elapsedVirtualTime = frame[0] * 1000;
-      frame = frames[++nextFrameIndex];
+    while (input && (input[0] * 1000 < targetTime)) {
+      input = inputs[++nextInputIndex];
     }
 
     pauseElapsedTime = targetTime;
@@ -185,13 +227,19 @@ function recording(src, { feed, now, setTimeout, setState, logger }, { idleTimeL
   }
 
   function step() {
-    let nextFrame = frames[nextFrameIndex];
+    let nextOutput = outputs[nextOutputIndex];
 
-    if (nextFrame !== undefined) {
-      feed(nextFrame[1]);
-      elapsedVirtualTime = nextFrame[0] * 1000;
+    if (nextOutput !== undefined) {
+      feed(nextOutput[1]);
+      const targetTime = nextOutput[0] * 1000;
+      elapsedVirtualTime = targetTime;
       pauseElapsedTime = elapsedVirtualTime;
-      nextFrameIndex++;
+      nextOutputIndex++;
+      let input = inputs[nextInputIndex];
+
+      while (input && (input[0] * 1000 < targetTime)) {
+        input = inputs[++nextInputIndex];
+      }
     }
 
     effectiveStartAt = null;
@@ -200,19 +248,19 @@ function recording(src, { feed, now, setTimeout, setState, logger }, { idleTimeL
   function getPoster(time) {
     const posterTime = time * 1000;
     const poster = [];
-    let nextFrameIndex = 0;
-    let frame = frames[0];
+    let nextOutputIndex = 0;
+    let output = outputs[0];
 
-    while (frame && (frame[0] * 1000 < posterTime)) {
-      poster.push(frame[1]);
-      frame = frames[++nextFrameIndex];
+    while (output && (output[0] * 1000 < posterTime)) {
+      poster.push(output[1]);
+      output = outputs[++nextOutputIndex];
     }
 
     return poster;
   }
 
   function getCurrentTime() {
-    if (timeoutId) {
+    if (outputTimeoutId) {
       return (now() - startTime) / 1000;
     } else {
       return (pauseElapsedTime ?? 0) / 1000;
@@ -231,72 +279,88 @@ function recording(src, { feed, now, setTimeout, setState, logger }, { idleTimeL
   }
 }
 
-function batchFrames(frames, logger, minFrameTime = 1.0 / 60) {
-  let prevFrame;
+function batcher(logger, minFrameTime = 1.0 / 60) {
+  let prevOutput;
 
-  return frames.transform(emit => {
+  return emit => {
     let ic = 0;
     let oc = 0;
 
     return {
-      step: frame => {
+      step: output => {
         ic++;
 
-        if (prevFrame === undefined) {
-          prevFrame = frame;
+        if (prevOutput === undefined) {
+          prevOutput = output;
           return;
         }
 
-        if (frame[0] - prevFrame[0] < minFrameTime) {
-          prevFrame[1] += frame[1];
+        if (output[0] - prevOutput[0] < minFrameTime) {
+          prevOutput[1] += output[1];
         } else {
-          emit(prevFrame);
-          prevFrame = frame;
+          emit(prevOutput);
+          prevOutput = output;
           oc++;
         }
       },
 
       flush: () => {
-        if (prevFrame !== undefined) {
-          emit(prevFrame);
+        if (prevOutput !== undefined) {
+          emit(prevOutput);
           oc++;
         }
 
         logger.debug(`batched ${ic} frames to ${oc} frames`);
       }
     }
-  });
+  };
 }
 
-function prepareFrames(frames, logger, idleTimeLimit = Infinity, startAt = 0, minFrameTime) {
+function prepare(recording, logger, { startAt = 0, idleTimeLimit, minFrameTime, inputOffset }) {
+  idleTimeLimit = idleTimeLimit ?? recording.idleTimeLimit ?? Infinity;
+  let { output, input } = recording;
   let prevT = 0;
   let shift = 0;
   let effectiveStartAt = startAt;
 
-  if (!(frames instanceof Stream)) {
-    frames = new Stream(frames);
+  if (!(output instanceof Stream)) {
+    output = new Stream(output);
   }
 
-  const fs = Array.from(batchFrames(frames, logger, minFrameTime).map(e => {
-    const delay = e[0] - prevT;
-    const delta = delay - idleTimeLimit;
-    prevT = e[0];
+  output = output
+    .transform(batcher(logger, minFrameTime))
+    .map(e => {
+      const delay = e[0] - prevT;
+      const delta = delay - idleTimeLimit;
+      prevT = e[0];
 
-    if (delta > 0) {
-      shift += delta;
+      if (delta > 0) {
+        shift += delta;
 
-      if (e[0] < startAt) {
-        effectiveStartAt -= delta;
+        if (e[0] < startAt) {
+          effectiveStartAt -= delta;
+        }
       }
+
+      return [e[0] - shift, e[1]];
+    })
+    .toArray();
+
+  const duration = output[output.length - 1][0];
+
+  if (inputOffset !== undefined) {
+    if (!(input instanceof Stream)) {
+      input = new Stream(input);
     }
 
-    return [e[0] - shift, e[1]];
-  }));
+    input = input.map(i => [i[0] + inputOffset, i[1]]);
+  }
 
-  return {
-    frames: fs,
-    effectiveStartAt: effectiveStartAt
+  if (!Array.isArray(input)) {
+    input = input.toArray();
   }
+
+  return { ...recording, output, input, duration, effectiveStartAt };
 }
 
 export { recording };
diff --git a/src/parser/README.md b/src/parser/README.md
index 3bb3e27e..2e445f87 100644
--- a/src/parser/README.md
+++ b/src/parser/README.md
@@ -3,16 +3,21 @@
 Parser is a function, which transforms a recording encoded in an arbitrary file
 format into a simple object representing a recording. Once the player fetches a
 file, it runs its contents through a parser, which turns it into a recording
-object ready to be played.
+object used by player's [recording driver](../driver/recording.js).
+
+## Data model of a recording
 
 asciinema player uses very simple internal representation of a recording. The
 object has following properties:
 
 - `cols` - number of terminal columns (terminal width in chars),
 - `rows` - number of terminal rows (terminal height in lines),
-- `frames` - iterable (e.g. array, generator) of frames, where each frame is a 2
-  element array, containing frame time (in seconds) and a text to print (or
-  specifically, to feed into virtual terminal emulator).
+- `output` - iterable (e.g. array, generator) of terminal writes, where each
+  item is a 2 element array, containing write time (in seconds) + data written to
+  a terminal,
+- `input` (optional) - iterable of terminal reads (individual key presses),
+  where each item is a 2 element array, containing read time (in seconds) and a
+  character that was read from keyboard.
 
 Example recording in its internal representation:
 
@@ -20,7 +25,7 @@ Example recording in its internal representation:
 {
   cols: 80,
   rows: 24,
-  frames: [
+  output: [
     [1.0, 'hello '],
     [2.0, 'world!']
   ]
@@ -47,6 +52,9 @@ parseAsciicast('{ "version": 2, "width": 80, "height": 24 }\n[1.0, "o", "hello "
 
 ## Custom parser
 
+Custom format parser can be built by implementing a function which takes file
+content and returns a value conforming to the above data model.
+
 The following example illustrates implementation of a custom parser:
 
 ```javascript
@@ -56,7 +64,7 @@ function parse(text) {
   return {
     cols: 80,
     rows: 24,
-    frames: [[1.0, "hello"], [2.0, " world!"]]
+    output: [[1.0, "hello"], [2.0, " world!"]]
   }
 };
 
@@ -73,20 +81,20 @@ makes the player fetch a file (`example.txt`) and pass it through the parser
 function.
 
 This parser is not quite there though because it ignores downloaded file's
-content, always returning hardcoded frames. Also, `cols` and `rows` are made up
+content, always returning hardcoded output. Also, `cols` and `rows` are made up
 as well - if possible they should reflect the size of a terminal at the time of
 recording, otherwise their values should be chosen to make the recording look
 legible. The example illustrates what kind of data the player expects though.
 
 A more realistic example, where content of a file is actually used to construct
-frames, could look like this:
+output, could look like this:
 
 ```javascript
 function parseLogs(text) {
   return {
     cols: 80,
     rows: 24,
-    frames: text.split('\n').map((line, i) => [i * 0.5, line + '\n'])
+    output: text.split('\n').map((line, i) => [i * 0.5, line + '\n'])
   }
 };
 
@@ -97,11 +105,11 @@ AsciinemaPlayer.create(
 ```
 
 `parseLogs` function parses a log file into a recording which prints one log
-line every half a second. This replays logs at a fixed rate.
+line every half a second.
 
-That's not very fun to watch. If log lines started with a timestamp (where 0.0
-means start of the recording) followed by log message then the timestamp could
-be used for frame timing.
+This replays logs at a fixed rate. That's not very fun to watch. If log lines
+started with a timestamp (where 0.0 means start of the recording) followed by
+log message then the timestamp could be used for output timing.
 
 For example:
 
@@ -115,11 +123,13 @@ For example:
 
 ```javascript
 function parseLogs(text) {
+  const pattern = /^([\d.]+) (.*)/;
+
   return {
     cols: 80,
     rows: 24,
-    frames: text.split('\n').map(line => {
-      const [_, time, message] = /^([\d.]+) (.*)/.exec(line);
+    output: text.split('\n').map(line => {
+      const [_, time, message] = pattern.exec(line);
       return [parseFloat(time), message + '\n']
     })
   }
@@ -139,7 +149,7 @@ function parseAsciimation(text) {
     cols: 67,
     rows: LINES_PER_FRAME - 1,
 
-    frames: function*() {
+    output: function*() {
       let time = 0;
       let prevFrameDuration = 0;
 
@@ -170,7 +180,7 @@ is defined by 14 lines. First of every 14 lines defines duration a frame should
 be displayed for (multiplied by a speed constant, by default `67` ms), while
 lines 2-14 define frame content - text to display.
 
-Note that `frames` in the above parser function is a generator (note `*` in
+Note that `output` in the above parser function is a generator (note `*` in
 `function*`) that is immediately called (note `()` after `}` at the end). In
-fact `frames` can be any iterable or iterator which is finite, which in practice
+fact `output` can be any iterable or iterator which is finite, which in practice
 means you can return an array or a finite generator, amongst others.
diff --git a/src/parser/asciicast.js b/src/parser/asciicast.js
index 1f80a9fd..9c8ce7aa 100644
--- a/src/parser/asciicast.js
+++ b/src/parser/asciicast.js
@@ -3,7 +3,7 @@ import Stream from '../stream';
 
 function parseAsciicast(data) {
   let header;
-  let events = new Stream([]);
+  let events;
 
   if (typeof data === 'string') {
     const result = parseJsonl(data);;
@@ -18,7 +18,7 @@ function parseAsciicast(data) {
     header = data;
   } else if (Array.isArray(data)) {
     header = data[0];
-    events = (new Stream(data)).drop(1);
+    events = data.slice(1, data.length);
   } else {
     throw 'invalid data';
   }
@@ -45,7 +45,8 @@ function parseJsonl(jsonl) {
   const events = new Stream(lines)
     .drop(1)
     .filter(l => l[0] === '[')
-    .map(l => JSON.parse(l));
+    .map(JSON.parse)
+    .toArray();
 
   return { header, events };
 }
@@ -53,7 +54,7 @@ function parseJsonl(jsonl) {
 function parseAsciicastV1(data) {
   let time = 0;
 
-  const frames = new Stream(data.stdout).map(e => {
+  const output = new Stream(data.stdout).map(e => {
     time += e[0];
     return [time, e[1]];
   });
@@ -61,20 +62,17 @@ function parseAsciicastV1(data) {
   return {
     cols: data.width,
     rows: data.height,
-    frames: frames
+    output
   }
 }
 
 function parseAsciicastV2(header, events) {
-  const frames = events
-    .filter(e => e[1] === 'o')
-    .map(e => [e[0], e[2]]);
-
   return {
     cols: header.width,
     rows: header.height,
-    idleTimeLimit: header.idle_time_limit,
-    frames: frames
+    output: (new Stream(events)).filter(e => e[1] === 'o').map(e => [e[0], e[2]]),
+    input: (new Stream(events)).filter(e => e[1] === 'i').map(e => [e[0], e[2]]),
+    idleTimeLimit: header.idle_time_limit
   }
 }
 
@@ -85,9 +83,8 @@ function unparseAsciicastV2(recording) {
     height: recording.rows,
   })
 
-  const events = Array.from(recording.frames)
-    .map(f => [f[0], 'o', f[1]])
-    .map(e => JSON.stringify(e))
+  const events = recording.output
+    .map(e => JSON.stringify([e[0], 'o', e[1]]))
     .join('\n');
 
   return `${header}\n${events}\n`;


Test patch:
diff --git a/src/driver/test.js b/src/driver/test.js
index f4e5cd16..7789703f 100644
--- a/src/driver/test.js
+++ b/src/driver/test.js
@@ -85,9 +85,9 @@ function benchmark({ url, iterations = 10 }, { feed, now }) {
 
   return {
     async init() {
-      const asciicast = parseAsciicast(await (await fetch(url)).text());
-      const { cols, rows } = asciicast;
-      frames = Array.from(asciicast.frames);
+      const recording = parseAsciicast(await (await fetch(url)).text());
+      const { cols, rows } = recording;
+      frames = Array.from(recording.output);
       const duration = frames[frames.length - 1][0];
 
       for (const [_, text] of frames) {