Class: ButterCut::EditorBase
- Inherits:
-
Object
- Object
- ButterCut::EditorBase
- Defined in:
- lib/buttercut/editor_base.rb
Overview
Shared functionality for editor-specific generators.
Constant Summary collapse
- DEFAULT_START_TIME =
"0s"- DEFAULT_INITIAL_OFFSET =
"0s"- DEFAULT_VOLUME_ADJUSTMENT =
"-13.100000000000001db"
Instance Attribute Summary collapse
-
#clips ⇒ Object
readonly
Returns the value of attribute clips.
-
#initial_offset ⇒ Object
readonly
Returns the value of attribute initial_offset.
-
#volume_adjustment ⇒ Object
readonly
Returns the value of attribute volume_adjustment.
Instance Method Summary collapse
- #add_fractions(frac1, frac2) ⇒ Object
- #audio_sample_rate(video_path) ⇒ Object
- #build_asset_map ⇒ Object
- #build_timeline_clips(asset_map, timeline_frame_duration) ⇒ Object
- #clip_timecode_fraction(video_path) ⇒ Object
- #clip_timecode_string(video_path) ⇒ Object
- #color_space(video_path) ⇒ Object
- #drop_frame_timecode?(timecode, rate_num, rate_denom, fps_nominal) ⇒ Boolean
- #drop_frames_for_rate(fps_nominal) ⇒ Object
- #duration_to_fraction(video_path) ⇒ Object
- #escape_xml(str) ⇒ Object
- #extract_metadata(video_path) ⇒ Object
- #format_audio_rate ⇒ Object
- #format_color_space ⇒ Object
- #format_frame_duration ⇒ Object
- #format_frame_rate ⇒ Object
- #format_height ⇒ Object
- #format_nominal_frame_rate ⇒ Object
- #format_width ⇒ Object
- #fraction_to_rational(value) ⇒ Object
- #frame_duration(video_path) ⇒ Object
- #frame_duration_rational_for(frame_duration_fraction) ⇒ Object
- #frame_rate(video_path) ⇒ Object
- #frames_for_fraction(duration_fraction, frame_duration_fraction) ⇒ Object
- #gcd(a, b) ⇒ Object
- #generate_uuid ⇒ Object
- #get_absolute_path(path) ⇒ Object
- #get_basename(filename) ⇒ Object
- #get_filename(path) ⇒ Object
-
#initialize(clips) ⇒ EditorBase
constructor
A new instance of EditorBase.
- #nominal_frame_rate(video_path) ⇒ Object
- #path_to_file_url(path) ⇒ Object
- #round_to_frame_boundary(time_value, frame_duration) ⇒ Object
- #save(filename) ⇒ Object
- #seconds_to_fraction(seconds) ⇒ Object
- #subtract_fractions(frac1, frac2) ⇒ Object
- #time_value_zero?(value) ⇒ Boolean
- #video_duration(video_path) ⇒ Object
- #video_height(video_path) ⇒ Object
- #video_width(video_path) ⇒ Object
Constructor Details
#initialize(clips) ⇒ EditorBase
Returns a new instance of EditorBase.
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
#clips ⇒ Object (readonly)
Returns the value of attribute clips.
14 15 16 |
# File 'lib/buttercut/editor_base.rb', line 14 def clips @clips end |
#initial_offset ⇒ Object (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_adjustment ⇒ Object (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_map ⇒ Object
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| = stream['tags'] next unless && ['timecode'] && !['timecode'].empty? return ['timecode'] end end = .dig('format', 'tags') if tc = ['timecode'] return tc unless tc.nil? || tc.empty? panasonic_xml = ['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
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("'", "'") 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_rate ⇒ Object
227 228 229 |
# File 'lib/buttercut/editor_base.rb', line 227 def format_audio_rate audio_sample_rate(@clips.first[:path]) end |
#format_color_space ⇒ Object
223 224 225 |
# File 'lib/buttercut/editor_base.rb', line 223 def format_color_space color_space(@clips.first[:path]) end |
#format_frame_duration ⇒ Object
211 212 213 |
# File 'lib/buttercut/editor_base.rb', line 211 def format_frame_duration frame_duration(@clips.first[:path]) end |
#format_frame_rate ⇒ Object
215 216 217 |
# File 'lib/buttercut/editor_base.rb', line 215 def format_frame_rate frame_rate(@clips.first[:path]) end |
#format_height ⇒ Object
207 208 209 |
# File 'lib/buttercut/editor_base.rb', line 207 def format_height video_height(@clips.first[:path]) end |
#format_nominal_frame_rate ⇒ Object
219 220 221 |
# File 'lib/buttercut/editor_base.rb', line 219 def format_nominal_frame_rate nominal_frame_rate(@clips.first[:path]) end |
#format_width ⇒ Object
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_uuid ⇒ Object
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.(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
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 |