test-recorder 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +3 -3
- data/.github/workflows/push_gem.yml +4 -4
- data/CHANGELOG.md +4 -0
- data/Rakefile +1 -1
- data/lib/test_recorder/cdp_recorder.rb +50 -20
- data/lib/test_recorder/frame_writer.rb +83 -0
- data/lib/test_recorder/matroska_writer.rb +177 -0
- data/lib/test_recorder/version.rb +1 -1
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f2df7b7454c1776d950afaadc9fcefdab037f2638a0a0f05ba7251ef08131428
|
|
4
|
+
data.tar.gz: 97e45933561090c772aef6a26849075626c7d8e9766d0e25c60a642841a38f36
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2ced41859d336565547e55de99b4574952f175bc3acb7ce6a6231c07ce32bab27483689f6a9c3967950d4da80145ea2002a0abe2ba3b6905c56fa9728902e004
|
|
7
|
+
data.tar.gz: 1657c90f69bcf393bb7e4905fde1a9e6afc74b7c5e864521a72bee0f4d3f97db9b1ceba28e466d321e7122ce2545548431ccd593a576e53f2b7c3c45d34214da
|
data/.github/workflows/ci.yml
CHANGED
|
@@ -20,14 +20,14 @@ jobs:
|
|
|
20
20
|
ruby: [ '4.0', '3.4', '3.3' ]
|
|
21
21
|
|
|
22
22
|
steps:
|
|
23
|
-
- uses: actions/checkout@
|
|
24
|
-
- uses: ruby/setup-ruby@v1
|
|
23
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
24
|
+
- uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0
|
|
25
25
|
with:
|
|
26
26
|
ruby-version: ${{ matrix.ruby }}
|
|
27
27
|
bundler-cache: true
|
|
28
28
|
- name: Cache ffmpeg
|
|
29
29
|
id: cache-ffmpeg
|
|
30
|
-
uses: actions/cache@
|
|
30
|
+
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
|
31
31
|
with:
|
|
32
32
|
path: ~/.local/bin/ffmpeg
|
|
33
33
|
key: ffmpeg-${{ runner.os }}-${{ env.FFMPEG_BUILD }}
|
|
@@ -19,15 +19,15 @@ jobs:
|
|
|
19
19
|
|
|
20
20
|
steps:
|
|
21
21
|
- name: Harden Runner
|
|
22
|
-
uses: step-security/harden-runner@
|
|
22
|
+
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
|
23
23
|
with:
|
|
24
24
|
egress-policy: audit
|
|
25
25
|
|
|
26
|
-
- uses: actions/checkout@
|
|
26
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
27
27
|
- name: Set up Ruby
|
|
28
|
-
uses: ruby/setup-ruby@
|
|
28
|
+
uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0
|
|
29
29
|
with:
|
|
30
30
|
bundler-cache: true
|
|
31
31
|
ruby-version: ruby
|
|
32
32
|
|
|
33
|
-
- uses: rubygems/release-gem@
|
|
33
|
+
- uses: rubygems/release-gem@7f9650160c1a4e7989fdc9855807bdbd421d8b6b # v1.4.1
|
data/CHANGELOG.md
CHANGED
data/Rakefile
CHANGED
|
@@ -6,7 +6,7 @@ Rake::TestTask.new(:test) do |t|
|
|
|
6
6
|
t.libs << "lib"
|
|
7
7
|
t.verbose = true
|
|
8
8
|
t.warning = true
|
|
9
|
-
t.test_files = ["test/integration_test.rb", "test/test_recorder_config_test.rb"]
|
|
9
|
+
t.test_files = ["test/integration_test.rb", "test/frame_writer_test.rb", "test/test_recorder_config_test.rb"]
|
|
10
10
|
end
|
|
11
11
|
|
|
12
12
|
task :default => :test
|
|
@@ -2,11 +2,18 @@ require "base64"
|
|
|
2
2
|
require "fileutils"
|
|
3
3
|
require "tempfile"
|
|
4
4
|
|
|
5
|
+
require "test_recorder/frame_writer"
|
|
6
|
+
|
|
5
7
|
module TestRecorder
|
|
6
8
|
class CdpRecorder
|
|
7
9
|
FFMPEG_ENCODE_OPTIONS = %w[-y -an -r 25 -c:v vp8 -qmin 0 -qmax 50 -crf 8 -deadline realtime -speed 8 -b:v 1M -threads 1].freeze
|
|
8
10
|
|
|
9
|
-
|
|
11
|
+
# ffmpeg's -t treats the given duration as an exclusive cutoff, dropping the very frame that
|
|
12
|
+
# marks the video's real length if it lands exactly on it. This nudges -t just past that frame
|
|
13
|
+
# so it is kept, without adding a duration long enough to be noticeable.
|
|
14
|
+
FFMPEG_DURATION_SLACK_S = 0.01
|
|
15
|
+
|
|
16
|
+
Record = Struct.new(:page, :io, :last_metadata_time, :last_metadata_received_at)
|
|
10
17
|
|
|
11
18
|
class << self
|
|
12
19
|
def record(devtools)
|
|
@@ -14,9 +21,9 @@ module TestRecorder
|
|
|
14
21
|
page = devtools.page
|
|
15
22
|
page.enable
|
|
16
23
|
|
|
17
|
-
record = Record.new(page
|
|
24
|
+
record = Record.new(page)
|
|
18
25
|
page.on(:screencast_frame) do |event|
|
|
19
|
-
write_frame(record, event
|
|
26
|
+
write_frame(record, event)
|
|
20
27
|
record.page.screencast_frame_ack(session_id: event["sessionId"])
|
|
21
28
|
end
|
|
22
29
|
record
|
|
@@ -25,17 +32,39 @@ module TestRecorder
|
|
|
25
32
|
|
|
26
33
|
private
|
|
27
34
|
|
|
28
|
-
def write_frame(record,
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
record.io.write(Base64.decode64(data))
|
|
32
|
-
record.frame_count += 1
|
|
35
|
+
def write_frame(record, event)
|
|
36
|
+
record.io&.write(Base64.decode64(event["data"]), frame_time(record, event))
|
|
33
37
|
rescue IOError
|
|
34
38
|
# A frame can still arrive after the recording was stopped and the file was closed.
|
|
35
39
|
rescue => e
|
|
36
40
|
warn "[TestRecorder] Failed to write a screencast frame: #{e.class}: #{e.message}"
|
|
37
41
|
end
|
|
38
42
|
|
|
43
|
+
# `metadata.timestamp` is the wall clock time of the frame swap, in seconds, on the browser's
|
|
44
|
+
# clock. The protocol marks it optional, so when it is missing, estimate it from the last
|
|
45
|
+
# frame that did carry one plus how much monotonic time has passed since. Falling back to
|
|
46
|
+
# this process's own wall clock instead would silently distort pacing whenever the browser
|
|
47
|
+
# runs on a different host than the driver, since the two wall clocks are not guaranteed to
|
|
48
|
+
# agree.
|
|
49
|
+
def frame_time(record, event)
|
|
50
|
+
metadata = event["metadata"]
|
|
51
|
+
timestamp = metadata && metadata["timestamp"]
|
|
52
|
+
|
|
53
|
+
if timestamp
|
|
54
|
+
record.last_metadata_time = timestamp
|
|
55
|
+
record.last_metadata_received_at = monotonic_time
|
|
56
|
+
timestamp
|
|
57
|
+
elsif record.last_metadata_time
|
|
58
|
+
record.last_metadata_time + (monotonic_time - record.last_metadata_received_at)
|
|
59
|
+
else
|
|
60
|
+
Time.now.to_f
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def monotonic_time
|
|
65
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
66
|
+
end
|
|
67
|
+
|
|
39
68
|
def records
|
|
40
69
|
@records ||= {}.compare_by_identity
|
|
41
70
|
end
|
|
@@ -51,16 +80,14 @@ module TestRecorder
|
|
|
51
80
|
@started = enabled
|
|
52
81
|
return unless @started
|
|
53
82
|
|
|
54
|
-
@tmp_video = Tempfile.new(["testrecorder", ".
|
|
83
|
+
@tmp_video = Tempfile.new(["testrecorder", ".mkv"])
|
|
55
84
|
@tmp_video.binmode
|
|
56
85
|
|
|
57
86
|
@record = self.class.record(page.driver.browser.devtools)
|
|
58
|
-
@
|
|
59
|
-
@record.
|
|
60
|
-
|
|
61
|
-
@every_nth_frame = TestRecorder.every_nth_frame
|
|
87
|
+
@frame_writer = FrameWriter.new(@tmp_video)
|
|
88
|
+
@record.io = @frame_writer
|
|
62
89
|
|
|
63
|
-
@record.page.start_screencast(format: "jpeg", quality: TestRecorder.jpeg_quality, max_width: TestRecorder.max_dimension, max_height: TestRecorder.max_dimension, every_nth_frame:
|
|
90
|
+
@record.page.start_screencast(format: "jpeg", quality: TestRecorder.jpeg_quality, max_width: TestRecorder.max_dimension, max_height: TestRecorder.max_dimension, every_nth_frame: TestRecorder.every_nth_frame)
|
|
64
91
|
end
|
|
65
92
|
|
|
66
93
|
def stop_and_discard
|
|
@@ -76,12 +103,14 @@ module TestRecorder
|
|
|
76
103
|
|
|
77
104
|
@record.io = nil
|
|
78
105
|
@record.page.stop_screencast
|
|
106
|
+
@frame_writer.finish
|
|
79
107
|
@tmp_video.flush
|
|
80
108
|
|
|
81
109
|
# Chrome sends a frame as soon as the screencast starts, so a test that fails
|
|
82
110
|
# before it draws anything still leaves behind the one frame of the blank page
|
|
83
111
|
# it started on.
|
|
84
|
-
if @
|
|
112
|
+
if @frame_writer.frame_count < 2
|
|
113
|
+
warn "[TestRecorder] The screencast captured no page updates, so no video was saved."
|
|
85
114
|
@tmp_video.close!
|
|
86
115
|
return ""
|
|
87
116
|
end
|
|
@@ -90,11 +119,12 @@ module TestRecorder
|
|
|
90
119
|
FileUtils.mkdir_p(video_dir)
|
|
91
120
|
video_path = video_dir.join(filename).to_s
|
|
92
121
|
|
|
93
|
-
#
|
|
94
|
-
#
|
|
95
|
-
#
|
|
96
|
-
#
|
|
97
|
-
|
|
122
|
+
# Each frame carries its own timestamp in the Matroska stream, so ffmpeg knows how long to
|
|
123
|
+
# hold it and duplicates frames to reach the constant output frame rate on its own. Without a
|
|
124
|
+
# known end, though, ffmpeg pads however long it likes past the last real timestamp, so -t
|
|
125
|
+
# caps the output at the video's real duration explicitly.
|
|
126
|
+
duration = @frame_writer.duration + FFMPEG_DURATION_SLACK_S
|
|
127
|
+
result = system("ffmpeg", "-loglevel", "error", "-f", "matroska", "-i", @tmp_video.path, "-t", duration.to_s, *FFMPEG_ENCODE_OPTIONS, video_path)
|
|
98
128
|
|
|
99
129
|
@tmp_video.close!
|
|
100
130
|
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
require "test_recorder/matroska_writer"
|
|
2
|
+
|
|
3
|
+
module TestRecorder
|
|
4
|
+
class FrameWriter
|
|
5
|
+
# How long the last frame stays visible at the end of the video. A test usually fails some
|
|
6
|
+
# time after the last repaint, and that last screen is the one worth looking at.
|
|
7
|
+
MIN_LAST_FRAME_DURATION = 1.0
|
|
8
|
+
MAX_LAST_FRAME_DURATION = 5.0
|
|
9
|
+
|
|
10
|
+
def initialize(io)
|
|
11
|
+
@container = MatroskaWriter.new(io)
|
|
12
|
+
@first_frame_time = nil
|
|
13
|
+
@last_frame = nil
|
|
14
|
+
@last_write_at = nil
|
|
15
|
+
@last_timestamp_ms = 0
|
|
16
|
+
@duration = nil
|
|
17
|
+
@frame_count = 0
|
|
18
|
+
@finished = false
|
|
19
|
+
# CDP dispatches every screencast frame event on its own thread, and `finish` can run on the
|
|
20
|
+
# main thread while a frame is still in flight, so all access to the state above and to the
|
|
21
|
+
# container is serialized through this.
|
|
22
|
+
@mutex = Mutex.new
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def empty?
|
|
26
|
+
@last_frame.nil?
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# How many seconds the finished video should run for. ffmpeg does not reliably infer this from
|
|
30
|
+
# the container alone, so `stop_and_save` passes it to ffmpeg explicitly. Only meaningful after
|
|
31
|
+
# `finish` has run.
|
|
32
|
+
attr_reader :duration
|
|
33
|
+
|
|
34
|
+
# Chrome sends a frame as soon as the screencast starts, so a test that fails before it draws
|
|
35
|
+
# anything still leaves behind the one frame of the blank page it started on. Callers use this
|
|
36
|
+
# to tell that apart from a real recording.
|
|
37
|
+
attr_reader :frame_count
|
|
38
|
+
|
|
39
|
+
def write(frame, time)
|
|
40
|
+
@mutex.synchronize do
|
|
41
|
+
return if @finished
|
|
42
|
+
|
|
43
|
+
@first_frame_time ||= time
|
|
44
|
+
write_at(frame, time)
|
|
45
|
+
@last_frame = frame
|
|
46
|
+
@last_write_at = monotonic_time
|
|
47
|
+
@frame_count += 1
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Repeats the last frame at the end of the recording so that it stays on screen, and so that
|
|
52
|
+
# the video covers the time between the last repaint and the end of the test. That interval is
|
|
53
|
+
# measured with the monotonic clock, so a wall clock adjustment cannot stretch or shrink it.
|
|
54
|
+
def finish
|
|
55
|
+
@mutex.synchronize do
|
|
56
|
+
return if @finished
|
|
57
|
+
|
|
58
|
+
@finished = true
|
|
59
|
+
return if empty?
|
|
60
|
+
|
|
61
|
+
elapsed = (monotonic_time - @last_write_at).clamp(MIN_LAST_FRAME_DURATION, MAX_LAST_FRAME_DURATION)
|
|
62
|
+
write_ms(@last_frame, @last_timestamp_ms + (elapsed * 1000).round)
|
|
63
|
+
@duration = @last_timestamp_ms / 1000.0
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def monotonic_time
|
|
70
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def write_at(frame, time)
|
|
74
|
+
write_ms(frame, ((time - @first_frame_time) * 1000).round)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def write_ms(frame, timestamp_ms)
|
|
78
|
+
timestamp_ms = [timestamp_ms, @last_timestamp_ms].max
|
|
79
|
+
@container.write_frame(frame, timestamp_ms)
|
|
80
|
+
@last_timestamp_ms = timestamp_ms
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
module TestRecorder
|
|
2
|
+
# Writes a Matroska stream holding a single MJPEG video track, where every frame carries the
|
|
3
|
+
# time it was captured. Chrome emits a screencast frame only when the page repaints, so frames
|
|
4
|
+
# are not evenly spaced; keeping the real times lets ffmpeg reproduce the original pacing
|
|
5
|
+
# instead of assuming a fixed frame rate.
|
|
6
|
+
#
|
|
7
|
+
# Written against the specifications:
|
|
8
|
+
# EBML https://datatracker.ietf.org/doc/html/rfc8794
|
|
9
|
+
# Matroska https://datatracker.ietf.org/doc/html/rfc9559
|
|
10
|
+
# Codecs https://datatracker.ietf.org/doc/html/draft-ietf-cellar-codec
|
|
11
|
+
#
|
|
12
|
+
# RFC 9559 updates RFC 8794, so read the two together: it makes 0x80 a legal EBML ID, which
|
|
13
|
+
# leaves 0xFF as the only reserved one. Codec IDs such as V_MJPEG are defined by neither, but by
|
|
14
|
+
# the codec document, which is still a draft.
|
|
15
|
+
#
|
|
16
|
+
# Only the elements those specifications require for this kind of stream are emitted. The stream
|
|
17
|
+
# is written as it is captured, so the sizes of the Segment and of each Cluster are not known
|
|
18
|
+
# when they start and are left unknown, which Matroska allows for those two elements alone.
|
|
19
|
+
class MatroskaWriter
|
|
20
|
+
# Element IDs, as defined by Matroska. The leading byte of an ID already encodes how many
|
|
21
|
+
# bytes the ID occupies, so an ID is written as the minimal big endian form of these numbers.
|
|
22
|
+
module Id
|
|
23
|
+
EBML = 0x1A45DFA3
|
|
24
|
+
EBML_VERSION = 0x4286
|
|
25
|
+
EBML_READ_VERSION = 0x42F7
|
|
26
|
+
EBML_MAX_ID_LENGTH = 0x42F2
|
|
27
|
+
EBML_MAX_SIZE_LENGTH = 0x42F3
|
|
28
|
+
DOC_TYPE = 0x4282
|
|
29
|
+
DOC_TYPE_VERSION = 0x4287
|
|
30
|
+
DOC_TYPE_READ_VERSION = 0x4285
|
|
31
|
+
SEGMENT = 0x18538067
|
|
32
|
+
INFO = 0x1549A966
|
|
33
|
+
TIMESTAMP_SCALE = 0x2AD7B1
|
|
34
|
+
MUXING_APP = 0x4D80
|
|
35
|
+
WRITING_APP = 0x5741
|
|
36
|
+
TRACKS = 0x1654AE6B
|
|
37
|
+
TRACK_ENTRY = 0xAE
|
|
38
|
+
TRACK_NUMBER = 0xD7
|
|
39
|
+
TRACK_UID = 0x73C5
|
|
40
|
+
TRACK_TYPE = 0x83
|
|
41
|
+
FLAG_LACING = 0x9C
|
|
42
|
+
CODEC_ID = 0x86
|
|
43
|
+
VIDEO = 0xE0
|
|
44
|
+
PIXEL_WIDTH = 0xB0
|
|
45
|
+
PIXEL_HEIGHT = 0xBA
|
|
46
|
+
CLUSTER = 0x1F43B675
|
|
47
|
+
TIMESTAMP = 0xE7
|
|
48
|
+
SIMPLE_BLOCK = 0xA3
|
|
49
|
+
end
|
|
50
|
+
private_constant :Id
|
|
51
|
+
|
|
52
|
+
# A size whose data bits are all ones means "unknown". One byte is enough to say that.
|
|
53
|
+
UNKNOWN_SIZE = [0xFF].pack("C")
|
|
54
|
+
|
|
55
|
+
# TimestampScale is given in nanoseconds per tick, so this makes every timestamp in the stream
|
|
56
|
+
# a number of milliseconds.
|
|
57
|
+
TIMESTAMP_SCALE_NS = 1_000_000
|
|
58
|
+
|
|
59
|
+
# A block states its time as a 16 bit signed offset from the timestamp of its cluster, so a
|
|
60
|
+
# cluster can only cover about 32 seconds. Matroska also recommends keeping clusters short,
|
|
61
|
+
# a few seconds at most.
|
|
62
|
+
MAX_CLUSTER_DURATION_MS = 5_000
|
|
63
|
+
|
|
64
|
+
TRACK_NUMBER = 1
|
|
65
|
+
TRACK_TYPE_VIDEO = 1
|
|
66
|
+
KEYFRAME = 0x80
|
|
67
|
+
|
|
68
|
+
def initialize(io)
|
|
69
|
+
@io = io
|
|
70
|
+
@cluster_timestamp_ms = nil
|
|
71
|
+
|
|
72
|
+
write_header
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Appends one JPEG, shown at the given number of milliseconds from the start of the stream.
|
|
76
|
+
# Timestamps must not go backwards.
|
|
77
|
+
def write_frame(frame, timestamp_ms)
|
|
78
|
+
open_cluster(timestamp_ms) if start_new_cluster?(timestamp_ms)
|
|
79
|
+
|
|
80
|
+
# The block header is written separately from the frame so that the frame, which is by far
|
|
81
|
+
# the largest part, never has to be copied into another string.
|
|
82
|
+
@io.write(simple_block_header(frame.bytesize, timestamp_ms - @cluster_timestamp_ms))
|
|
83
|
+
@io.write(frame)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def write_header
|
|
89
|
+
@io.write(element(Id::EBML, [
|
|
90
|
+
element(Id::EBML_VERSION, uint(1)),
|
|
91
|
+
element(Id::EBML_READ_VERSION, uint(1)),
|
|
92
|
+
element(Id::EBML_MAX_ID_LENGTH, uint(4)),
|
|
93
|
+
element(Id::EBML_MAX_SIZE_LENGTH, uint(8)),
|
|
94
|
+
element(Id::DOC_TYPE, "matroska"),
|
|
95
|
+
element(Id::DOC_TYPE_VERSION, uint(4)),
|
|
96
|
+
element(Id::DOC_TYPE_READ_VERSION, uint(2))
|
|
97
|
+
].join))
|
|
98
|
+
|
|
99
|
+
# Everything below lives inside the segment, which stays open until the file ends.
|
|
100
|
+
@io.write(id(Id::SEGMENT) + UNKNOWN_SIZE)
|
|
101
|
+
|
|
102
|
+
@io.write(element(Id::INFO, [
|
|
103
|
+
element(Id::TIMESTAMP_SCALE, uint(TIMESTAMP_SCALE_NS)),
|
|
104
|
+
element(Id::MUXING_APP, "test-recorder"),
|
|
105
|
+
element(Id::WRITING_APP, "test-recorder")
|
|
106
|
+
].join))
|
|
107
|
+
|
|
108
|
+
@io.write(element(Id::TRACKS, element(Id::TRACK_ENTRY, [
|
|
109
|
+
element(Id::TRACK_NUMBER, uint(TRACK_NUMBER)),
|
|
110
|
+
element(Id::TRACK_UID, uint(TRACK_NUMBER)),
|
|
111
|
+
element(Id::TRACK_TYPE, uint(TRACK_TYPE_VIDEO)),
|
|
112
|
+
# Lacing packs several frames into one block. Each frame here is a whole picture with its
|
|
113
|
+
# own time, so it is turned off.
|
|
114
|
+
element(Id::FLAG_LACING, uint(0)),
|
|
115
|
+
element(Id::CODEC_ID, "V_MJPEG"),
|
|
116
|
+
# PixelWidth and PixelHeight are mandatory, but the frame size is not known before the
|
|
117
|
+
# first frame arrives, and the browser may change it mid recording. Every JPEG states its
|
|
118
|
+
# own size, so declare the smallest allowed picture and let the decoder use the real one.
|
|
119
|
+
# Declaring a plausible size instead is actively harmful: ffmpeg then compares it with the
|
|
120
|
+
# size it decodes and can read a shorter picture as one field of an interlaced frame.
|
|
121
|
+
element(Id::VIDEO, element(Id::PIXEL_WIDTH, uint(1)) + element(Id::PIXEL_HEIGHT, uint(1)))
|
|
122
|
+
].join)))
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def start_new_cluster?(timestamp_ms)
|
|
126
|
+
@cluster_timestamp_ms.nil? || timestamp_ms - @cluster_timestamp_ms > MAX_CLUSTER_DURATION_MS
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def open_cluster(timestamp_ms)
|
|
130
|
+
@io.write(id(Id::CLUSTER) + UNKNOWN_SIZE)
|
|
131
|
+
@io.write(element(Id::TIMESTAMP, uint(timestamp_ms)))
|
|
132
|
+
@cluster_timestamp_ms = timestamp_ms
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def simple_block_header(frame_length, relative_ms)
|
|
136
|
+
track = vint(TRACK_NUMBER)
|
|
137
|
+
# The payload is the track number, the 16 bit offset, the flags and then the frame itself.
|
|
138
|
+
size = track.bytesize + 2 + 1 + frame_length
|
|
139
|
+
|
|
140
|
+
id(Id::SIMPLE_BLOCK) + vint(size) + track + [relative_ms].pack("s>") + [KEYFRAME].pack("C")
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# An element is its ID, then its length as a variable size integer, then its content.
|
|
144
|
+
def element(element_id, content)
|
|
145
|
+
id(element_id) + vint(content.bytesize) + content
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# A variable size integer starts with a marker bit that says how many bytes it spans, and
|
|
149
|
+
# carries the value in the bits after it. A value that would fill every remaining bit is
|
|
150
|
+
# reserved to mean "unknown", so it needs one more byte.
|
|
151
|
+
def vint(value)
|
|
152
|
+
width = 1
|
|
153
|
+
width += 1 until value < (1 << (7 * width)) - 1
|
|
154
|
+
|
|
155
|
+
bytes = big_endian_bytes(value, width)
|
|
156
|
+
bytes[0] |= 1 << (8 - width)
|
|
157
|
+
bytes.pack("C*")
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Numbers are stored big endian, in as few bytes as they fit into.
|
|
161
|
+
def uint(value)
|
|
162
|
+
big_endian_bytes(value).pack("C*")
|
|
163
|
+
end
|
|
164
|
+
alias id uint
|
|
165
|
+
|
|
166
|
+
def big_endian_bytes(value, width = nil)
|
|
167
|
+
bytes = []
|
|
168
|
+
loop do
|
|
169
|
+
bytes.unshift(value & 0xFF)
|
|
170
|
+
value >>= 8
|
|
171
|
+
break if value.zero?
|
|
172
|
+
end
|
|
173
|
+
bytes.unshift(0) while width && bytes.size < width
|
|
174
|
+
bytes
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: test-recorder
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yuji Yaginuma
|
|
@@ -84,6 +84,8 @@ files:
|
|
|
84
84
|
- lib/test-recorder.rb
|
|
85
85
|
- lib/test_recorder.rb
|
|
86
86
|
- lib/test_recorder/cdp_recorder.rb
|
|
87
|
+
- lib/test_recorder/frame_writer.rb
|
|
88
|
+
- lib/test_recorder/matroska_writer.rb
|
|
87
89
|
- lib/test_recorder/rails.rb
|
|
88
90
|
- lib/test_recorder/rails/setup_and_teardown.rb
|
|
89
91
|
- lib/test_recorder/rspec.rb
|
|
@@ -109,7 +111,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
109
111
|
- !ruby/object:Gem::Version
|
|
110
112
|
version: '0'
|
|
111
113
|
requirements: []
|
|
112
|
-
rubygems_version:
|
|
114
|
+
rubygems_version: 4.0.16
|
|
113
115
|
specification_version: 4
|
|
114
116
|
summary: Automatically record videos when tests failed.
|
|
115
117
|
test_files: []
|