Class: Gemba::RecorderDecoder

Inherits:
Object
  • Object
show all
Defined in:
lib/gemba/recorder_decoder.rb

Overview

Decodes a .grec file and encodes it to a playable video via ffmpeg.

Two-pass approach to avoid writing massive intermediate files:

Pass 1: Extract audio to a small tempfile (~10MB/min), count frames,
        collect per-frame change percentages.
Pass 2: Decode video frames one at a time and pipe to ffmpeg's stdin.

Only one decoded video frame is in memory at a time, so RAM usage stays constant regardless of recording length.

Examples:

info = RecorderDecoder.decode("recording.grec", "output.mp4")
puts "Encoded #{info[:frame_count]} frames to #{info[:output_path]}"

Quick stats without encoding

stats = RecorderDecoder.stats("recording.grec")
puts "#{stats[:frame_count]} frames, avg #{stats[:avg_change_pct].round(1)}% change"

Defined Under Namespace

Classes: FfmpegNotFound, FormatError

Constant Summary collapse

DEFAULT_VIDEO_CODEC =
'libx264'
DEFAULT_AUDIO_CODEC =
'aac'

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(trec_path, output_path, video_codec: DEFAULT_VIDEO_CODEC, audio_codec: DEFAULT_AUDIO_CODEC, scale: nil, ffmpeg_args: nil, progress: true) ⇒ RecorderDecoder

Returns a new instance of RecorderDecoder.



59
60
61
62
63
64
65
66
67
68
69
# File 'lib/gemba/recorder_decoder.rb', line 59

def initialize(trec_path, output_path, video_codec: DEFAULT_VIDEO_CODEC,
               audio_codec: DEFAULT_AUDIO_CODEC, scale: nil,
               ffmpeg_args: nil, progress: true)
  @trec_path = trec_path
  @output_path = output_path
  @video_codec = video_codec
  @audio_codec = audio_codec
  @scale = scale
  @ffmpeg_args = ffmpeg_args
  @progress = progress
end

Class Method Details

.decode(trec_path, output_path, video_codec: DEFAULT_VIDEO_CODEC, audio_codec: DEFAULT_AUDIO_CODEC, scale: nil, ffmpeg_args: nil, progress: true) ⇒ Hash

Decode a .grec file and encode to a playable video file.

Parameters:

  • trec_path (String)

    path to .grec file

  • output_path (String)

    output video path (e.g. “out.mp4”, “out.mkv”)

  • video_codec (String) (defaults to: DEFAULT_VIDEO_CODEC)

    ffmpeg video codec (default: libx264)

  • audio_codec (String) (defaults to: DEFAULT_AUDIO_CODEC)

    ffmpeg audio codec (default: aac)

  • scale (Integer, nil) (defaults to: nil)

    output scale factor (nil = native)

  • ffmpeg_args (Array<String>, nil) (defaults to: nil)

    raw ffmpeg output args (overrides codecs)

  • progress (Boolean) (defaults to: true)

    show encoding progress (default: true)

Returns:

  • (Hash)

    :output_path, :frame_count, :width, :height, :fps, :avg_change_pct, :raw_video_size, :audio_rate, :audio_channels



51
52
53
54
55
56
57
# File 'lib/gemba/recorder_decoder.rb', line 51

def self.decode(trec_path, output_path, video_codec: DEFAULT_VIDEO_CODEC,
                audio_codec: DEFAULT_AUDIO_CODEC, scale: nil,
                ffmpeg_args: nil, progress: true)
  new(trec_path, output_path,
      video_codec: video_codec, audio_codec: audio_codec,
      scale: scale, ffmpeg_args: ffmpeg_args, progress: progress).decode
end

.stats(trec_path) ⇒ Hash

Quick scan of a .grec file — no ffmpeg needed. Reads header + per-frame change bytes, skips video/audio data.

Parameters:

  • trec_path (String)

Returns:

  • (Hash)

    :frame_count, :width, :height, :fps, :duration, :avg_change_pct, :raw_video_size, :audio_rate, :audio_channels



37
38
39
# File 'lib/gemba/recorder_decoder.rb', line 37

def self.stats(trec_path)
  new(trec_path, nil).stats
end

Instance Method Details

#decodeObject



112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
# File 'lib/gemba/recorder_decoder.rb', line 112

def decode
  check_ffmpeg!

  header = nil
  frame_count = 0
  total_change = 0

  # Pass 1: parse header, extract audio to tempfile, count frames.
  # Video chunks are skipped (seek, not read) to keep this fast.
  audio_tmp = Tempfile.new(['trec_audio', '.raw'])
  audio_tmp.binmode

  File.open(@trec_path, 'rb') do |f|
    header = read_header(f)

    until f.eof?
      break if at_footer?(f)

      change_pct = f.read(1)&.unpack1('C') or break
      total_change += change_pct

      video_len = read_u32(f) or break
      f.seek(video_len, IO::SEEK_CUR)

      audio_len = read_u32(f) or break
      audio_tmp.write(f.read(audio_len)) if audio_len > 0

      frame_count += 1
    end
  end

  audio_tmp.flush
  fps = header[:fps_num].to_f / header[:fps_den]
  frame_size = header[:width] * header[:height] * 4

  # Pass 2: decode video frames and pipe to ffmpeg.
  encode(header, fps, audio_tmp.path, frame_count)

  {
    output_path: @output_path,
    frame_count: frame_count,
    width: header[:width],
    height: header[:height],
    fps: fps,
    avg_change_pct: frame_count > 0 ? total_change.to_f / frame_count : 0,
    raw_video_size: frame_count * frame_size,
    audio_rate: header[:audio_rate],
    audio_channels: header[:audio_channels],
  }
ensure
  audio_tmp&.close!
end

#statsObject

Quick stats scan — reads only header + 1 byte per frame.



72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
# File 'lib/gemba/recorder_decoder.rb', line 72

def stats
  header = nil
  frame_count = 0
  total_change = 0

  File.open(@trec_path, 'rb') do |f|
    header = read_header(f)

    until f.eof?
      break if at_footer?(f)

      change_pct = f.read(1)&.unpack1('C') or break
      total_change += change_pct

      video_len = read_u32(f) or break
      f.seek(video_len, IO::SEEK_CUR)

      audio_len = read_u32(f) or break
      f.seek(audio_len, IO::SEEK_CUR)

      frame_count += 1
    end
  end

  fps = header[:fps_num].to_f / header[:fps_den]
  frame_size = header[:width] * header[:height] * 4

  {
    frame_count: frame_count,
    width: header[:width],
    height: header[:height],
    fps: fps,
    duration: frame_count / fps,
    avg_change_pct: frame_count > 0 ? total_change.to_f / frame_count : 0,
    raw_video_size: frame_count * frame_size,
    audio_rate: header[:audio_rate],
    audio_channels: header[:audio_channels],
  }
end