julik-edl 0.0.8
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.
- data/History.txt +39 -0
- data/Manifest.txt +23 -0
- data/README.txt +57 -0
- data/Rakefile +18 -0
- data/SPECS.txt +96 -0
- data/illustr/edl-explain.ai +1182 -2
- data/lib/edl.rb +319 -0
- data/lib/edl/cutter.rb +34 -0
- data/lib/edl/event.rb +146 -0
- data/lib/edl/grabber.rb +29 -0
- data/lib/edl/timewarp.rb +45 -0
- data/lib/edl/transition.rb +23 -0
- data/test/samples/45S_SAMPLE.EDL +48 -0
- data/test/samples/FCP_REVERSE.EDL +9 -0
- data/test/samples/REVERSE.EDL +3 -0
- data/test/samples/SIMPLE_DISSOLVE.EDL +9 -0
- data/test/samples/SPEEDUP_AND_FADEOUT.EDL +11 -0
- data/test/samples/SPEEDUP_REVERSE_AND_FADEOUT.EDL +11 -0
- data/test/samples/SPLICEME.EDL +5 -0
- data/test/samples/TIMEWARP.EDL +5 -0
- data/test/samples/TIMEWARP_HALF.EDL +5 -0
- data/test/samples/TRAILER_EDL.edl +0 -0
- data/test/test_edl.rb +664 -0
- metadata +115 -0
data/lib/edl.rb
ADDED
@@ -0,0 +1,319 @@
|
|
1
|
+
require "rubygems"
|
2
|
+
require "timecode"
|
3
|
+
require 'stringio'
|
4
|
+
|
5
|
+
require File.dirname(__FILE__) + '/edl/event'
|
6
|
+
require File.dirname(__FILE__) + '/edl/transition'
|
7
|
+
require File.dirname(__FILE__) + '/edl/timewarp'
|
8
|
+
require File.dirname(__FILE__) + '/edl/parser'
|
9
|
+
|
10
|
+
# A simplistic EDL parser
|
11
|
+
module EDL
|
12
|
+
VERSION = "0.0.8"
|
13
|
+
DEFAULT_FPS = 25.0
|
14
|
+
|
15
|
+
# Represents an EDL, is returned from the parser. Traditional operation is functional style, i.e.
|
16
|
+
# edl.renumbered.without_transitions.without_generators
|
17
|
+
class List < Array
|
18
|
+
|
19
|
+
def events #:nodoc:
|
20
|
+
STDERR.puts "EDL::List#events is deprecated and will be removed, use EDL::List as an array instead"
|
21
|
+
self
|
22
|
+
end
|
23
|
+
|
24
|
+
# Return the same EDL with all dissolves stripped and replaced by the clips underneath
|
25
|
+
def without_transitions
|
26
|
+
# Find dissolves
|
27
|
+
cpy = []
|
28
|
+
each_with_index do | e, i |
|
29
|
+
# A dissolve always FOLLOWS the incoming clip
|
30
|
+
if e.ends_with_transition?
|
31
|
+
dissolve = self[i+1]
|
32
|
+
len = dissolve.transition.duration.to_i
|
33
|
+
|
34
|
+
# The dissolve contains the OUTGOING clip, we are the INCOMING. Extend the
|
35
|
+
# incoming clip by the length of the dissolve, that's the whole mission actually
|
36
|
+
incoming = e.copy_properties_to(e.class.new)
|
37
|
+
incoming.src_end_tc += len
|
38
|
+
incoming.rec_end_tc += len
|
39
|
+
|
40
|
+
outgoing = dissolve.copy_properties_to(Event.new)
|
41
|
+
|
42
|
+
# Add the A suffix to the ex-dissolve
|
43
|
+
outgoing.num += 'A'
|
44
|
+
|
45
|
+
# Take care to join the two if they overlap - TODO
|
46
|
+
cpy << incoming
|
47
|
+
cpy << outgoing
|
48
|
+
elsif e.has_transition?
|
49
|
+
# Skip, already handled on the previous clip
|
50
|
+
else
|
51
|
+
cpy << e.dup
|
52
|
+
end
|
53
|
+
end
|
54
|
+
# Do not renumber events
|
55
|
+
# (0...cpy.length).map{|e| cpy[e].num = "%03d" % e }
|
56
|
+
self.class.new(cpy)
|
57
|
+
end
|
58
|
+
|
59
|
+
# Return the same EDL, with events renumbered starting from 001
|
60
|
+
def renumbered
|
61
|
+
renumed = self.dup
|
62
|
+
pad = renumed.length.to_s.length
|
63
|
+
pad = 3 if pad < 3
|
64
|
+
|
65
|
+
(0...renumed.length).map{|e| renumed[e].num = "%0#{pad}d" % (e+1) }
|
66
|
+
self.class.new(renumed)
|
67
|
+
end
|
68
|
+
|
69
|
+
# Return the same EDL with all timewarps expanded to native length. Clip length
|
70
|
+
# changes have rippling effect on footage that comes after the timewarped clip
|
71
|
+
# (so this is best used in concert with the original EDL where record TC is pristine)
|
72
|
+
def without_timewarps
|
73
|
+
self.class.new(
|
74
|
+
map do | e |
|
75
|
+
if e.has_timewarp?
|
76
|
+
repl = e.copy_properties_to(e.class.new)
|
77
|
+
from, to = e.timewarp.actual_src_start_tc, e.timewarp.actual_src_end_tc
|
78
|
+
repl.src_start_tc, repl.src_end_tc, repl.timewarp = from, to, nil
|
79
|
+
repl
|
80
|
+
else
|
81
|
+
e
|
82
|
+
end
|
83
|
+
end
|
84
|
+
)
|
85
|
+
end
|
86
|
+
|
87
|
+
# Return the same EDL without AX, BL and other GEN events (like slug, text and solids).
|
88
|
+
# Usually used in concert with "without_transitions"
|
89
|
+
def without_generators
|
90
|
+
self.class.new(self.reject{|e| e.generator? })
|
91
|
+
end
|
92
|
+
|
93
|
+
# Return the list of clips used by this EDL at full capture length
|
94
|
+
def capture_list
|
95
|
+
without_generators.without_timewarps.spliced.from_zero
|
96
|
+
end
|
97
|
+
|
98
|
+
# Return the same EDL with the first event starting at 00:00:00:00 and all subsequent events
|
99
|
+
# shifted accordingly
|
100
|
+
def from_zero
|
101
|
+
shift_by = self[0].rec_start_tc
|
102
|
+
self.class.new(
|
103
|
+
map do | original |
|
104
|
+
e = original.dup
|
105
|
+
e.rec_start_tc = (e.rec_start_tc - shift_by)
|
106
|
+
e.rec_end_tc = (e.rec_end_tc - shift_by)
|
107
|
+
e
|
108
|
+
end
|
109
|
+
)
|
110
|
+
end
|
111
|
+
|
112
|
+
# Return the same EDL with neighbouring clips joined at cuts where applicable (if a clip
|
113
|
+
# is divided in two pieces it will be spliced). Most useful in combination with without_timewarps
|
114
|
+
def spliced
|
115
|
+
spliced_edl = inject([]) do | spliced, cur |
|
116
|
+
latest = spliced[-1]
|
117
|
+
# Append to latest if splicable
|
118
|
+
if latest && (latest.reel == cur.reel) && (cur.src_start_tc == (latest.src_end_tc + 1))
|
119
|
+
latest.src_end_tc = cur.src_end_tc
|
120
|
+
latest.rec_end_tc = cur.rec_end_tc
|
121
|
+
else
|
122
|
+
spliced << cur.dup
|
123
|
+
end
|
124
|
+
spliced
|
125
|
+
end
|
126
|
+
self.class.new(spliced_edl)
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
#:stopdoc:
|
131
|
+
|
132
|
+
# A generic matcher
|
133
|
+
class Matcher
|
134
|
+
class ApplyError < RuntimeError
|
135
|
+
def initialize(msg, line)
|
136
|
+
super("%s - offending line was '%s'" % [msg, line])
|
137
|
+
end
|
138
|
+
end
|
139
|
+
|
140
|
+
def initialize(with_regexp)
|
141
|
+
@regexp = with_regexp
|
142
|
+
end
|
143
|
+
|
144
|
+
def matches?(line)
|
145
|
+
!!(line =~ @regexp)
|
146
|
+
end
|
147
|
+
|
148
|
+
def apply(stack, line)
|
149
|
+
STDERR.puts "Skipping #{line}"
|
150
|
+
end
|
151
|
+
end
|
152
|
+
|
153
|
+
# EDL clip comment matcher, a generic one
|
154
|
+
class CommentMatcher < Matcher
|
155
|
+
def initialize
|
156
|
+
super(/\* (.+)/)
|
157
|
+
end
|
158
|
+
|
159
|
+
def apply(stack, line)
|
160
|
+
stack[-1].comments.push("* %s" % line.scan(@regexp).flatten.pop.strip)
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
# Fallback matcher for things like FINAL CUT PRO REEL
|
165
|
+
class FallbackMatcher < Matcher
|
166
|
+
def initialize
|
167
|
+
super(/^(\w)(.+)/)
|
168
|
+
end
|
169
|
+
|
170
|
+
def apply(stack, line)
|
171
|
+
begin
|
172
|
+
stack[-1].comments << line.scan(@regexp).flatten.join.strip
|
173
|
+
rescue NoMethodError
|
174
|
+
raise ApplyError.new("Line can only be a comment but no event was on the stack", line)
|
175
|
+
end
|
176
|
+
end
|
177
|
+
end
|
178
|
+
|
179
|
+
# Clip name matcher
|
180
|
+
class NameMatcher < Matcher
|
181
|
+
def initialize
|
182
|
+
super(/\* FROM CLIP NAME:(\s+)(.+)/)
|
183
|
+
end
|
184
|
+
|
185
|
+
def apply(stack, line)
|
186
|
+
stack[-1].clip_name = line.scan(@regexp).flatten.pop.strip
|
187
|
+
CommentMatcher.new.apply(stack, line)
|
188
|
+
end
|
189
|
+
end
|
190
|
+
|
191
|
+
class EffectMatcher < Matcher
|
192
|
+
def initialize
|
193
|
+
super(/\* EFFECT NAME:(\s+)(.+)/)
|
194
|
+
end
|
195
|
+
|
196
|
+
def apply(stack, line)
|
197
|
+
stack[-1].transition.effect = line.scan(@regexp).flatten.pop.strip
|
198
|
+
CommentMatcher.new.apply(stack, line)
|
199
|
+
end
|
200
|
+
end
|
201
|
+
|
202
|
+
class TimewarpMatcher < Matcher
|
203
|
+
|
204
|
+
attr_reader :fps
|
205
|
+
|
206
|
+
def initialize(fps)
|
207
|
+
@fps = fps
|
208
|
+
@regexp = /M2(\s+)(\w+)(\s+)(\-?\d+\.\d+)(\s+)(\d{1,2}):(\d{1,2}):(\d{1,2}):(\d{1,2})/
|
209
|
+
end
|
210
|
+
|
211
|
+
def apply(stack, line)
|
212
|
+
matches = line.scan(@regexp).flatten.map{|e| e.strip}.reject{|e| e.nil? || e.empty?}
|
213
|
+
|
214
|
+
from_reel = matches.shift
|
215
|
+
fps = matches.shift
|
216
|
+
|
217
|
+
begin
|
218
|
+
# FIXME
|
219
|
+
tw_start_source_tc = Parser.timecode_from_line_elements(matches, @fps)
|
220
|
+
rescue Timecode::Error => e
|
221
|
+
raise ApplyError, "Invalid TC in timewarp (#{e})", line
|
222
|
+
end
|
223
|
+
|
224
|
+
evt_with_tw = stack.reverse.find{|e| e.src_start_tc == tw_start_source_tc && e.reel == from_reel }
|
225
|
+
|
226
|
+
unless evt_with_tw
|
227
|
+
raise ApplyError, "Cannot find event marked by timewarp", line
|
228
|
+
else
|
229
|
+
tw = Timewarp.new
|
230
|
+
tw.actual_framerate, tw.clip = fps.to_f, evt_with_tw
|
231
|
+
evt_with_tw.timewarp = tw
|
232
|
+
end
|
233
|
+
end
|
234
|
+
end
|
235
|
+
|
236
|
+
# Drop frame goodbye
|
237
|
+
TC = /(\d{1,2}):(\d{1,2}):(\d{1,2}):(\d{1,2})/
|
238
|
+
|
239
|
+
class EventMatcher < Matcher
|
240
|
+
|
241
|
+
# 021 009 V C 00:39:04:21 00:39:05:09 01:00:26:17 01:00:27:05
|
242
|
+
EVENT_PAT = /(\d+)(\s+)(\w+)(\s+)(\w+)(\s+)(\w+)(\s+)((\w+\s+)?)#{TC} #{TC} #{TC} #{TC}/
|
243
|
+
|
244
|
+
attr_reader :fps
|
245
|
+
|
246
|
+
def initialize(some_fps)
|
247
|
+
super(EVENT_PAT)
|
248
|
+
@fps = some_fps
|
249
|
+
end
|
250
|
+
|
251
|
+
def apply(stack, line)
|
252
|
+
|
253
|
+
matches = line.scan(@regexp).shift
|
254
|
+
props = {}
|
255
|
+
|
256
|
+
# FIrst one is the event number
|
257
|
+
props[:num] = matches.shift
|
258
|
+
matches.shift
|
259
|
+
|
260
|
+
# Then the reel
|
261
|
+
props[:reel] = matches.shift
|
262
|
+
matches.shift
|
263
|
+
|
264
|
+
# Then the track
|
265
|
+
props[:track] = matches.shift
|
266
|
+
matches.shift
|
267
|
+
|
268
|
+
# Then the type
|
269
|
+
props[:transition] = matches.shift
|
270
|
+
matches.shift
|
271
|
+
|
272
|
+
# Then the optional generator group - skip for now
|
273
|
+
if props[:transition] != 'C'
|
274
|
+
props[:duration] = matches.shift.strip
|
275
|
+
else
|
276
|
+
matches.shift
|
277
|
+
end
|
278
|
+
matches.shift
|
279
|
+
|
280
|
+
# Then the timecodes
|
281
|
+
[:src_start_tc, :src_end_tc, :rec_start_tc, :rec_end_tc].each do | k |
|
282
|
+
begin
|
283
|
+
props[k] = EDL::Parser.timecode_from_line_elements(matches, @fps)
|
284
|
+
rescue Timecode::Error => e
|
285
|
+
raise ApplyError, "Cannot parse timecode - #{e}", line
|
286
|
+
end
|
287
|
+
end
|
288
|
+
|
289
|
+
evt = Event.new
|
290
|
+
transition_idx = props.delete(:transition)
|
291
|
+
evt.transition = case transition_idx
|
292
|
+
when 'D'
|
293
|
+
d = Dissolve.new
|
294
|
+
d.duration = props.delete(:duration).to_i
|
295
|
+
d
|
296
|
+
when /W(\d+)/
|
297
|
+
w = Wipe.new
|
298
|
+
w.duration = props.delete(:duration).to_i
|
299
|
+
w.smpte_wipe_index = transition_idx.gsub(/W/, '')
|
300
|
+
w
|
301
|
+
else
|
302
|
+
nil
|
303
|
+
end
|
304
|
+
|
305
|
+
# Give a hint on the incoming clip as well
|
306
|
+
if evt.transition && stack[-1]
|
307
|
+
stack[-1].outgoing_transition_duration = evt.transition.duration
|
308
|
+
end
|
309
|
+
|
310
|
+
props.each_pair { | k, v | evt.send("#{k}=", v) }
|
311
|
+
|
312
|
+
stack << evt
|
313
|
+
evt # FIXME - we dont need to return this is only used by tests
|
314
|
+
end
|
315
|
+
end
|
316
|
+
|
317
|
+
#:startdoc:
|
318
|
+
|
319
|
+
end
|
data/lib/edl/cutter.rb
ADDED
@@ -0,0 +1,34 @@
|
|
1
|
+
module EDL
|
2
|
+
# Can chop an offline edit into events according to the EDL
|
3
|
+
class Cutter #:nodoc:
|
4
|
+
def initialize(source_path)
|
5
|
+
@source_path = source_path
|
6
|
+
end
|
7
|
+
|
8
|
+
def cut(edl)
|
9
|
+
source_for_cutting = edl.from_zero #.without_transitions.without_generators
|
10
|
+
# We need to use the original length in record
|
11
|
+
source_for_cutting.events.each do | evt |
|
12
|
+
cut_segment(evt, evt.rec_start_tc, evt.rec_start_tc + evt.length)
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
def cut_segment(evt, start_at, end_at)
|
17
|
+
STDERR.puts "Cutting #{@source_path} from #{start_at} to #{end_at} - #{evt.num}"
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
class FFMpegCutter < Cutter #:nodoc:
|
22
|
+
def cut_segment(evt, start_at, end_at)
|
23
|
+
source_dir, source_file = File.dirname(@source_path), File.basename(@source_path)
|
24
|
+
dest_segment = File.join(source_dir, ('%s_%s' % [evt.num, source_file]))
|
25
|
+
# dest_segment.gsub!(/\.mov$/i, '.mp4')
|
26
|
+
|
27
|
+
offset = end_at - start_at
|
28
|
+
|
29
|
+
cmd = "/opt/local/bin/ffmpeg -i #{@source_path} -ss #{start_at} -vframes #{offset.total} -vcodec photojpeg -acodec copy #{dest_segment}"
|
30
|
+
#puts cmd
|
31
|
+
`#{cmd}`
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
data/lib/edl/event.rb
ADDED
@@ -0,0 +1,146 @@
|
|
1
|
+
module EDL
|
2
|
+
# Represents an edit event (or, more specifically, an EDL line denoting a clip being part of an EDL event)
|
3
|
+
class Event
|
4
|
+
# Event number as in the EDL
|
5
|
+
attr_accessor :num
|
6
|
+
|
7
|
+
# Reel name as in the EDL
|
8
|
+
attr_accessor :reel
|
9
|
+
|
10
|
+
# Event tracks as in the EDL
|
11
|
+
attr_accessor :track
|
12
|
+
|
13
|
+
# Source start timecode of the start frame as in the EDL,
|
14
|
+
# no timewarps or dissolves included
|
15
|
+
attr_accessor :src_start_tc
|
16
|
+
|
17
|
+
# Source end timecode of the last frame as in the EDL,
|
18
|
+
# no timewarps or dissolves included
|
19
|
+
attr_accessor :src_end_tc
|
20
|
+
|
21
|
+
# Record start timecode of the event in the master as in the EDL
|
22
|
+
attr_accessor :rec_start_tc
|
23
|
+
|
24
|
+
# Record end timecode of the event in the master as in the EDL,
|
25
|
+
# outgoing transition is not included
|
26
|
+
attr_accessor :rec_end_tc
|
27
|
+
|
28
|
+
# Array of comment lines verbatim (all comments are included)
|
29
|
+
attr_accessor :comments
|
30
|
+
|
31
|
+
# Clip name contained in FROM CLIP NAME: comment
|
32
|
+
attr_accessor :clip_name
|
33
|
+
|
34
|
+
# Timewarp metadata (an EDL::Timewarp), or nil if no retime is made
|
35
|
+
attr_accessor :timewarp
|
36
|
+
|
37
|
+
# Incoming transition metadata (EDL::Transition), or nil if no transition is used
|
38
|
+
attr_accessor :transition
|
39
|
+
|
40
|
+
# How long is the incoming transition on the next event
|
41
|
+
attr_accessor :outgoing_transition_duration
|
42
|
+
|
43
|
+
# Where is this event located in the original file
|
44
|
+
attr_accessor :line_number
|
45
|
+
|
46
|
+
def initialize(opts = {})
|
47
|
+
opts.each_pair{|k,v| send("#{k}=", v) }
|
48
|
+
yield(self) if block_given?
|
49
|
+
end
|
50
|
+
|
51
|
+
# Output a textual description (will not work as an EDL line!)
|
52
|
+
def to_s
|
53
|
+
%w( num reel track src_start_tc src_end_tc rec_start_tc rec_end_tc).map{|a| self.send(a).to_s}.join(" ")
|
54
|
+
end
|
55
|
+
|
56
|
+
def inspect
|
57
|
+
to_s
|
58
|
+
end
|
59
|
+
|
60
|
+
def comments #:nodoc:
|
61
|
+
@comments ||= []
|
62
|
+
@comments
|
63
|
+
end
|
64
|
+
|
65
|
+
def outgoing_transition_duration #:nodoc:
|
66
|
+
@outgoing_transition_duration ||= 0
|
67
|
+
end
|
68
|
+
|
69
|
+
# Is the clip reversed in the edit?
|
70
|
+
def reverse?
|
71
|
+
(timewarp && timewarp.reverse?)
|
72
|
+
end
|
73
|
+
alias_method :reversed?, :reverse?
|
74
|
+
|
75
|
+
def copy_properties_to(evt)
|
76
|
+
%w( num reel track src_start_tc src_end_tc rec_start_tc rec_end_tc).each do | k |
|
77
|
+
evt.send("#{k}=", send(k)) if evt.respond_to?(k)
|
78
|
+
end
|
79
|
+
evt
|
80
|
+
end
|
81
|
+
|
82
|
+
# Returns true if the clip starts with a transiton (not a jump cut)
|
83
|
+
def has_transition?
|
84
|
+
!!@transition
|
85
|
+
end
|
86
|
+
alias_method :starts_with_transition?, :has_transition?
|
87
|
+
|
88
|
+
# The duration of the incoming transition, or 0 if no transition is used
|
89
|
+
def incoming_transition_duration
|
90
|
+
@transition ? @transition.duration : 0
|
91
|
+
end
|
92
|
+
|
93
|
+
# Returns true if the clip ends with a transition (if the next clip starts with a transition)
|
94
|
+
def ends_with_transition?
|
95
|
+
outgoing_transition_duration > 0
|
96
|
+
end
|
97
|
+
|
98
|
+
# Returns true if the clip has a timewarp (speed ramp, motion memory, you name it)
|
99
|
+
def has_timewarp?
|
100
|
+
!timewarp.nil?
|
101
|
+
end
|
102
|
+
|
103
|
+
# Is this a black slug?
|
104
|
+
def black?
|
105
|
+
reel == 'BL'
|
106
|
+
end
|
107
|
+
alias_method :slug?, :black?
|
108
|
+
|
109
|
+
# Get the record length of the event (how long it occupies in the EDL without an eventual outgoing transition)
|
110
|
+
def rec_length
|
111
|
+
(rec_end_tc.to_i - rec_start_tc.to_i).to_i
|
112
|
+
end
|
113
|
+
|
114
|
+
# Get the record length of the event (how long it occupies in the EDL with an eventual outgoing transition)
|
115
|
+
def rec_length_with_transition
|
116
|
+
rec_length + outgoing_transition_duration.to_i
|
117
|
+
end
|
118
|
+
|
119
|
+
# How long does the capture need to be to complete this event including timewarps and transitions
|
120
|
+
def src_length
|
121
|
+
@timewarp ? @timewarp.actual_length_of_source : rec_length_with_transition
|
122
|
+
end
|
123
|
+
|
124
|
+
alias_method :capture_length, :src_length
|
125
|
+
|
126
|
+
# Capture from (and including!) this timecode to complete this event including timewarps and transitions
|
127
|
+
def capture_from_tc
|
128
|
+
@timewarp ? @timewarp.source_used_from : src_start_tc
|
129
|
+
end
|
130
|
+
|
131
|
+
# Capture up to (but not including!) this timecode to complete this event including timewarps and transitions
|
132
|
+
def capture_to_tc
|
133
|
+
@timewarp ? @timewarp.source_used_upto : (src_end_tc + outgoing_transition_duration)
|
134
|
+
end
|
135
|
+
|
136
|
+
# Speed of this clip in percent relative to the source speed. 100 for non-timewarped events
|
137
|
+
def speed
|
138
|
+
@timewarp ? @timewarp.speed : 100.0
|
139
|
+
end
|
140
|
+
|
141
|
+
# Returns true if this event is a generator
|
142
|
+
def generator?
|
143
|
+
black? || (%(AX GEN).include?(reel))
|
144
|
+
end
|
145
|
+
end
|
146
|
+
end
|