Class: ButterCut::EditorBase

Inherits:
Object
  • Object
show all
Defined in:
lib/buttercut/editor_base.rb

Overview

Shared functionality for editor-specific generators.

Direct Known Subclasses

FCP7, FCPX

Constant Summary collapse

DEFAULT_START_TIME =
"0s"
DEFAULT_INITIAL_OFFSET =
"0s"
DEFAULT_VOLUME_ADJUSTMENT =
"-13.100000000000001db"

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(clips) ⇒ EditorBase

Returns a new instance of EditorBase.

Raises:

  • (ArgumentError)


16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
# File 'lib/buttercut/editor_base.rb', line 16

def initialize(clips)
  raise ArgumentError, "No clips provided" if clips.nil? || clips.empty?

  clips.each_with_index do |clip, index|
    unless clip.is_a?(Hash)
      raise ArgumentError, "Clip at index #{index} must be a hash, got #{clip.class}"
    end
    unless clip.key?(:path)
      raise ArgumentError, "Clip at index #{index} must have a 'path' key"
    end
  end

  relative_paths = clips.select { |clip| !Pathname.new(clip[:path]).absolute? }
  unless relative_paths.empty?
    paths = relative_paths.map { |clip| clip[:path] }.join(', ')
    raise ArgumentError, "All video file paths must be absolute paths. Relative paths found: #{paths}"
  end

  @clips = clips
  @initial_offset = DEFAULT_INITIAL_OFFSET
  @volume_adjustment = DEFAULT_VOLUME_ADJUSTMENT

  @metadata_cache = {}
  @clips.each do |clip|
    path = clip[:path]
    @metadata_cache[path] = (path)
  end
end

Instance Attribute Details

#clipsObject (readonly)

Returns the value of attribute clips.



14
15
16
# File 'lib/buttercut/editor_base.rb', line 14

def clips
  @clips
end

#initial_offsetObject (readonly)

Returns the value of attribute initial_offset.



14
15
16
# File 'lib/buttercut/editor_base.rb', line 14

def initial_offset
  @initial_offset
end

#volume_adjustmentObject (readonly)

Returns the value of attribute volume_adjustment.



14
15
16
# File 'lib/buttercut/editor_base.rb', line 14

def volume_adjustment
  @volume_adjustment
end

Instance Method Details

#add_fractions(frac1, frac2) ⇒ Object



238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
# File 'lib/buttercut/editor_base.rb', line 238

def add_fractions(frac1, frac2)
  return frac2 if frac1 == "0s"
  return frac1 if frac2 == "0s"

  num1, denom1 = frac1.match(/(\d+)\/(\d+)/).captures.map(&:to_i)
  num2, denom2 = frac2.match(/(\d+)\/(\d+)/).captures.map(&:to_i)

  result_num = num1 * denom2 + num2 * denom1
  result_denom = denom1 * denom2

  divisor = gcd(result_num, result_denom)
  result_num /= divisor
  result_denom /= divisor

  "#{result_num}/#{result_denom}s"
end

#audio_sample_rate(video_path) ⇒ Object



86
87
88
89
90
# File 'lib/buttercut/editor_base.rb', line 86

def audio_sample_rate(video_path)
   = (video_path)
  audio_stream = ['streams'].find { |s| s['codec_type'] == 'audio' }
  audio_stream['sample_rate']
end

#build_asset_mapObject



351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
# File 'lib/buttercut/editor_base.rb', line 351

def build_asset_map
  file_to_asset = {}
  @clips.each do |clip_def|
    video_file_path = clip_def[:path]
    abs_path = get_absolute_path(video_file_path)
    next if file_to_asset.key?(abs_path)

    asset_id = deterministic_asset_id(abs_path)
    asset_uid = deterministic_asset_uid(abs_path)
    filename = get_filename(video_file_path)
    file_url = path_to_file_url(video_file_path)

    file_to_asset[abs_path] = {
      asset_id: asset_id,
      asset_uid: asset_uid,
      abs_path: abs_path,
      filename: filename,
      basename: get_basename(filename),
      file_url: file_url,
      asset_duration: duration_to_fraction(video_file_path),
      audio_rate: audio_sample_rate(video_file_path),
      timecode: clip_timecode_fraction(video_file_path),
      frame_duration: frame_duration(video_file_path),
      frame_rate: frame_rate(video_file_path),
      width: video_width(video_file_path),
      height: video_height(video_file_path),
      color_space: color_space(video_file_path)
    }
  end
  file_to_asset
end

#build_timeline_clips(asset_map, timeline_frame_duration) ⇒ Object



383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
# File 'lib/buttercut/editor_base.rb', line 383

def build_timeline_clips(asset_map, timeline_frame_duration)
  current_offset = initial_offset
  clips = @clips.map do |clip_def|
    abs_path = get_absolute_path(clip_def[:path])
    asset_info = asset_map.fetch(abs_path)
    asset_frame_duration = asset_info[:frame_duration] || timeline_frame_duration

    start_at_raw = clip_def[:start_at] || DEFAULT_START_TIME
    start_at = round_to_frame_boundary(start_at_raw, asset_frame_duration)

    base_timecode = asset_info[:timecode] || "0s"
    clip_start = add_fractions(base_timecode, start_at)

    duration_info = compute_clip_duration(clip_def, asset_info, start_at, asset_frame_duration, timeline_frame_duration)

    clip_data = {
      asset: asset_info,
      asset_id: asset_info[:asset_id],
      filename: asset_info[:filename],
      start: clip_start,
      duration: duration_info[:timeline],
      source_duration: duration_info[:asset],
      timeline_offset: current_offset,
      source_in: start_at,
      clip_definition: clip_def
    }

    current_offset = add_fractions(current_offset, clip_data[:duration])
    clip_data
  end

  [clips, current_offset]
end

#clip_timecode_fraction(video_path) ⇒ Object



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
# File 'lib/buttercut/editor_base.rb', line 126

def clip_timecode_fraction(video_path)
  timecode = clip_timecode_string(video_path)
  return "0s" if timecode.nil? || timecode.strip.empty?

  parts = timecode.strip.tr(';', ':').split(':').map(&:to_i)
  return "0s" unless parts.length == 4

  hours, minutes, seconds, frames = parts
  fps_nominal = nominal_frame_rate(video_path)
  return "0s" if fps_nominal <= 0

  rate_num, rate_denom = frame_rate(video_path).split('/').map(&:to_i)
  return "0s" if rate_denom.zero? || rate_num.zero?

  drop_frame = drop_frame_timecode?(timecode, rate_num, rate_denom, fps_nominal)

  total_frames = if drop_frame
    drop_frames_per_minute = drop_frames_for_rate(fps_nominal)
    total_minutes = hours * 60 + minutes
    dropped_frames = drop_frames_per_minute * (total_minutes - (total_minutes / 10))
    (((hours * 3600 + minutes * 60 + seconds) * fps_nominal) + frames) - dropped_frames
  else
    ((hours * 3600 + minutes * 60 + seconds) * fps_nominal) + frames
  end

  return "0s" if total_frames.negative?

  start_num = total_frames * rate_denom
  start_denom = rate_num

  divisor = gcd(start_num, start_denom)
  "#{start_num / divisor}/#{start_denom / divisor}s"
end

#clip_timecode_string(video_path) ⇒ Object



99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
# File 'lib/buttercut/editor_base.rb', line 99

def clip_timecode_string(video_path)
   = (video_path)

  if ['streams']
    ['streams'].each do |stream|
      tags = stream['tags']
      next unless tags && tags['timecode'] && !tags['timecode'].empty?

      return tags['timecode']
    end
  end

  format_tags = .dig('format', 'tags')
  if format_tags
    tc = format_tags['timecode']
    return tc unless tc.nil? || tc.empty?

    panasonic_xml = format_tags['com.panasonic.Semi-Pro.metadata.xml']
    if panasonic_xml
      match = panasonic_xml.match(/<StartTimecode>([^<]+)<\/StartTimecode>/)
      return match[1].strip if match
    end
  end

  nil
end

#color_space(video_path) ⇒ Object



174
175
176
177
178
179
180
181
182
183
184
185
186
187
# File 'lib/buttercut/editor_base.rb', line 174

def color_space(video_path)
   = (video_path)
  video_stream = ['streams'].find { |s| s['codec_type'] == 'video' }

  cs = video_stream['color_space']
  cp = video_stream['color_primaries']
  ct = video_stream['color_transfer']

  if cs == 'bt709' || cp == 'bt709' || ct == 'bt709'
    "1-1-1 (Rec. 709)"
  else
    "1-1-1 (Rec. 709)"
  end
end

#drop_frame_timecode?(timecode, rate_num, rate_denom, fps_nominal) ⇒ Boolean

Returns:

  • (Boolean)


160
161
162
163
164
# File 'lib/buttercut/editor_base.rb', line 160

def drop_frame_timecode?(timecode, rate_num, rate_denom, fps_nominal)
  return false unless timecode.include?(';')
  return false unless fps_nominal == 30 || fps_nominal == 60
  (rate_num == 30000 && rate_denom == 1001) || (rate_num == 60000 && rate_denom == 1001)
end

#drop_frames_for_rate(fps_nominal) ⇒ Object



166
167
168
169
170
171
172
# File 'lib/buttercut/editor_base.rb', line 166

def drop_frames_for_rate(fps_nominal)
  case fps_nominal
  when 60 then 4
  when 30 then 2
  else 0
  end
end

#duration_to_fraction(video_path) ⇒ Object



189
190
191
192
193
194
195
196
197
198
199
200
201
# File 'lib/buttercut/editor_base.rb', line 189

def duration_to_fraction(video_path)
  duration_seconds = video_duration(video_path)
  rate = frame_rate(video_path)
  numerator, denominator = rate.split('/').map(&:to_i)

  total_frames = (duration_seconds * numerator / denominator).round

  duration_num = total_frames * denominator
  duration_denom = numerator

  divisor = gcd(duration_num, duration_denom)
  "#{duration_num / divisor}/#{duration_denom / divisor}s"
end

#escape_xml(str) ⇒ Object



346
347
348
349
# File 'lib/buttercut/editor_base.rb', line 346

def escape_xml(str)
  return "" if str.nil?
  CGI.escapeHTML(str).gsub("&#39;", "&apos;")
end

#extract_metadata(video_path) ⇒ Object



53
54
55
# File 'lib/buttercut/editor_base.rb', line 53

def (video_path)
  @metadata_cache[video_path]
end

#format_audio_rateObject



227
228
229
# File 'lib/buttercut/editor_base.rb', line 227

def format_audio_rate
  audio_sample_rate(@clips.first[:path])
end

#format_color_spaceObject



223
224
225
# File 'lib/buttercut/editor_base.rb', line 223

def format_color_space
  color_space(@clips.first[:path])
end

#format_frame_durationObject



211
212
213
# File 'lib/buttercut/editor_base.rb', line 211

def format_frame_duration
  frame_duration(@clips.first[:path])
end

#format_frame_rateObject



215
216
217
# File 'lib/buttercut/editor_base.rb', line 215

def format_frame_rate
  frame_rate(@clips.first[:path])
end

#format_heightObject



207
208
209
# File 'lib/buttercut/editor_base.rb', line 207

def format_height
  video_height(@clips.first[:path])
end

#format_nominal_frame_rateObject



219
220
221
# File 'lib/buttercut/editor_base.rb', line 219

def format_nominal_frame_rate
  nominal_frame_rate(@clips.first[:path])
end

#format_widthObject



203
204
205
# File 'lib/buttercut/editor_base.rb', line 203

def format_width
  video_width(@clips.first[:path])
end

#fraction_to_rational(value) ⇒ Object



417
418
419
420
421
422
423
424
425
426
427
428
# File 'lib/buttercut/editor_base.rb', line 417

def fraction_to_rational(value)
  value = seconds_to_fraction(value) if value.is_a?(Numeric)
  return Rational(0, 1) if value == "0s"

  if (match = value.match(%r{\A(\d+)\/(\d+)s\z}))
    Rational(match[1].to_i, match[2].to_i)
  elsif (match = value.match(%r{\A(\d+)s\z}))
    Rational(match[1].to_i, 1)
  else
    raise ArgumentError, "Unsupported time format: #{value.inspect}"
  end
end

#frame_duration(video_path) ⇒ Object



80
81
82
83
84
# File 'lib/buttercut/editor_base.rb', line 80

def frame_duration(video_path)
  rate = frame_rate(video_path)
  numerator, denominator = rate.split('/').map(&:to_i)
  "#{denominator}/#{numerator}s"
end

#frame_duration_rational_for(frame_duration_fraction) ⇒ Object



436
437
438
# File 'lib/buttercut/editor_base.rb', line 436

def frame_duration_rational_for(frame_duration_fraction)
  fraction_to_rational(frame_duration_fraction)
end

#frame_rate(video_path) ⇒ Object



74
75
76
77
78
# File 'lib/buttercut/editor_base.rb', line 74

def frame_rate(video_path)
   = (video_path)
  video_stream = ['streams'].find { |s| s['codec_type'] == 'video' }
  video_stream['r_frame_rate']
end

#frames_for_fraction(duration_fraction, frame_duration_fraction) ⇒ Object



430
431
432
433
434
# File 'lib/buttercut/editor_base.rb', line 430

def frames_for_fraction(duration_fraction, frame_duration_fraction)
  duration_rational = fraction_to_rational(duration_fraction)
  frame_rational = fraction_to_rational(frame_duration_fraction)
  ((duration_rational / frame_rational).round).to_i
end

#gcd(a, b) ⇒ Object



231
232
233
234
235
236
# File 'lib/buttercut/editor_base.rb', line 231

def gcd(a, b)
  while b != 0
    a, b = b, a % b
  end
  a
end

#generate_uuidObject



49
50
51
# File 'lib/buttercut/editor_base.rb', line 49

def generate_uuid
  SecureRandom.uuid
end

#get_absolute_path(path) ⇒ Object



337
338
339
# File 'lib/buttercut/editor_base.rb', line 337

def get_absolute_path(path)
  File.expand_path(path)
end

#get_basename(filename) ⇒ Object



333
334
335
# File 'lib/buttercut/editor_base.rb', line 333

def get_basename(filename)
  File.basename(filename, File.extname(filename))
end

#get_filename(path) ⇒ Object



329
330
331
# File 'lib/buttercut/editor_base.rb', line 329

def get_filename(path)
  File.basename(path)
end

#nominal_frame_rate(video_path) ⇒ Object



92
93
94
95
96
97
# File 'lib/buttercut/editor_base.rb', line 92

def nominal_frame_rate(video_path)
  rate_num, rate_denom = frame_rate(video_path).split('/').map(&:to_i)
  return 0 if rate_denom.zero?

  (rate_num.to_f / rate_denom).round
end

#path_to_file_url(path) ⇒ Object



341
342
343
344
# File 'lib/buttercut/editor_base.rb', line 341

def path_to_file_url(path)
  abs_path = get_absolute_path(path)
  "file://#{abs_path.gsub(' ', '%20')}"
end

#round_to_frame_boundary(time_value, frame_duration) ⇒ Object



273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
# File 'lib/buttercut/editor_base.rb', line 273

def round_to_frame_boundary(time_value, frame_duration)
  return "0s" if time_value == "0s" || time_value == 0
  time_value = seconds_to_fraction(time_value) if time_value.is_a?(Numeric)

  if time_value.match(/^(\d+)s$/)
    time_num = Regexp.last_match(1).to_i
    time_denom = 1
  else
    time_num, time_denom = time_value.match(/(\d+)\/(\d+)/).captures.map(&:to_i)
  end

  frame_num, frame_denom = frame_duration.match(/(\d+)\/(\d+)/).captures.map(&:to_i)

  frames_exact = (time_num * frame_denom).to_f / (time_denom * frame_num)
  frames_rounded = frames_exact.round

  result_num = frames_rounded * frame_num
  result_denom = frame_denom

  divisor = gcd(result_num, result_denom)
  "#{result_num / divisor}/#{result_denom / divisor}s"
end

#save(filename) ⇒ Object



45
46
47
# File 'lib/buttercut/editor_base.rb', line 45

def save(filename)
  File.write(filename, to_xml)
end

#seconds_to_fraction(seconds) ⇒ Object



262
263
264
265
266
267
268
269
270
271
# File 'lib/buttercut/editor_base.rb', line 262

def seconds_to_fraction(seconds)
  return "0s" if seconds == 0 || seconds == "0s"
  return seconds if seconds.is_a?(String)
  seconds = seconds.to_f if seconds.is_a?(Integer)

  denominator = 10000
  numerator = (seconds * denominator).round
  divisor = gcd(numerator, denominator)
  "#{numerator / divisor}/#{denominator / divisor}s"
end

#subtract_fractions(frac1, frac2) ⇒ Object



296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
# File 'lib/buttercut/editor_base.rb', line 296

def subtract_fractions(frac1, frac2)
  frac1 = seconds_to_fraction(frac1) if frac1.is_a?(Numeric)
  frac2 = seconds_to_fraction(frac2) if frac2.is_a?(Numeric)

  return frac1 if frac2 == "0s"
  return "0s" if frac1 == frac2

  if frac1.match(/^(\d+)s$/)
    num1 = Regexp.last_match(1).to_i
    denom1 = 1
  else
    num1, denom1 = frac1.match(/(\d+)\/(\d+)/).captures.map(&:to_i)
  end

  if frac2.match(/^(\d+)s$/)
    num2 = Regexp.last_match(1).to_i
    denom2 = 1
  else
    num2, denom2 = frac2.match(/(\d+)\/(\d+)/).captures.map(&:to_i)
  end

  result_num = num1 * denom2 - num2 * denom1
  result_denom = denom1 * denom2

  return "0s" if result_num <= 0

  divisor = gcd(result_num, result_denom)
  result_num /= divisor
  result_denom /= divisor

  "#{result_num}/#{result_denom}s"
end

#time_value_zero?(value) ⇒ Boolean

Returns:

  • (Boolean)


255
256
257
258
259
260
# File 'lib/buttercut/editor_base.rb', line 255

def time_value_zero?(value)
  return true if value.nil?
  return true if value == 0 || value == 0.0
  return true if value == "0s"
  false
end

#video_duration(video_path) ⇒ Object



69
70
71
72
# File 'lib/buttercut/editor_base.rb', line 69

def video_duration(video_path)
   = (video_path)
  ['format']['duration'].to_f
end

#video_height(video_path) ⇒ Object



63
64
65
66
67
# File 'lib/buttercut/editor_base.rb', line 63

def video_height(video_path)
   = (video_path)
  video_stream = ['streams'].find { |s| s['codec_type'] == 'video' }
  video_stream['height']
end

#video_width(video_path) ⇒ Object



57
58
59
60
61
# File 'lib/buttercut/editor_base.rb', line 57

def video_width(video_path)
   = (video_path)
  video_stream = ['streams'].find { |s| s['codec_type'] == 'video' }
  video_stream['width']
end