marcandre-flvedit 0.6.1
Sign up to get free protection for your applications and to get access to all the features.
- data/CHANGELOG.rdoc +5 -0
- data/LICENSE +24 -0
- data/README.rdoc +90 -0
- data/Rakefile +131 -0
- data/VERSION.yml +4 -0
- data/bin/flvedit +14 -0
- data/lib/flv.rb +24 -0
- data/lib/flv/audio.rb +66 -0
- data/lib/flv/base.rb +38 -0
- data/lib/flv/body.rb +57 -0
- data/lib/flv/edit.rb +20 -0
- data/lib/flv/edit/options.rb +162 -0
- data/lib/flv/edit/processor.rb +3 -0
- data/lib/flv/edit/processor/add.rb +67 -0
- data/lib/flv/edit/processor/base.rb +209 -0
- data/lib/flv/edit/processor/command_line.rb +23 -0
- data/lib/flv/edit/processor/cut.rb +27 -0
- data/lib/flv/edit/processor/debug.rb +30 -0
- data/lib/flv/edit/processor/head.rb +16 -0
- data/lib/flv/edit/processor/join.rb +52 -0
- data/lib/flv/edit/processor/meta_data_maker.rb +127 -0
- data/lib/flv/edit/processor/print.rb +13 -0
- data/lib/flv/edit/processor/printer.rb +27 -0
- data/lib/flv/edit/processor/reader.rb +30 -0
- data/lib/flv/edit/processor/save.rb +28 -0
- data/lib/flv/edit/processor/update.rb +27 -0
- data/lib/flv/edit/runner.rb +23 -0
- data/lib/flv/edit/version.rb +15 -0
- data/lib/flv/event.rb +40 -0
- data/lib/flv/file.rb +41 -0
- data/lib/flv/header.rb +37 -0
- data/lib/flv/packing.rb +140 -0
- data/lib/flv/tag.rb +62 -0
- data/lib/flv/timestamp.rb +124 -0
- data/lib/flv/util/double_check.rb +22 -0
- data/lib/flv/video.rb +73 -0
- data/test/fixtures/corrupted.flv +0 -0
- data/test/fixtures/short.flv +0 -0
- data/test/fixtures/tags.xml +39 -0
- data/test/test_flv.rb +145 -0
- data/test/test_flv_edit.rb +32 -0
- data/test/test_flv_edit_results.rb +27 -0
- data/test/test_helper.rb +9 -0
- data/test/text_flv_edit_results/add_tags.txt +132 -0
- data/test/text_flv_edit_results/cut_from.txt +114 -0
- data/test/text_flv_edit_results/cut_key.txt +20 -0
- data/test/text_flv_edit_results/debug.txt +132 -0
- data/test/text_flv_edit_results/debug_limited.txt +18 -0
- data/test/text_flv_edit_results/debug_range.txt +32 -0
- data/test/text_flv_edit_results/join.txt +237 -0
- data/test/text_flv_edit_results/print.txt +16 -0
- data/test/text_flv_edit_results/stop.txt +38 -0
- data/test/text_flv_edit_results/update.txt +33 -0
- metadata +134 -0
data/lib/flv/event.rb
ADDED
@@ -0,0 +1,40 @@
|
|
1
|
+
module FLV
|
2
|
+
# The body of a tag containing meta data, cue points or last second information
|
3
|
+
# These behave like a Hash. The keys should be symbols
|
4
|
+
# while the values can be about any type, including arrays and hashes.
|
5
|
+
class Event < Hash
|
6
|
+
include Packable
|
7
|
+
include Body
|
8
|
+
TYPICAL_EVENTS = [:onMetaData, :onCuePoint, :onCaption, :onCaptionInfo, :onLastSecond, :onEvent]
|
9
|
+
attr_accessor :event
|
10
|
+
|
11
|
+
def initialize(event = :onMetaData, h = {})
|
12
|
+
self.replace h
|
13
|
+
self.event = event.to_sym
|
14
|
+
end
|
15
|
+
|
16
|
+
def read_packed(io,options) #:nodoc:
|
17
|
+
len = io.pos_change do
|
18
|
+
evt, h = io >>:flv_value >>:flv_value
|
19
|
+
self.event = evt.to_sym
|
20
|
+
replace h
|
21
|
+
end
|
22
|
+
FLV::Util.double_check :size, options[:bytes], len
|
23
|
+
end
|
24
|
+
|
25
|
+
def write_packed(io,*) #:nodoc:
|
26
|
+
io << [event.to_s, :flv_value] << [self, :flv_value]
|
27
|
+
end
|
28
|
+
|
29
|
+
def debug(format, *)
|
30
|
+
format.values(:event => event)
|
31
|
+
format.values(self)
|
32
|
+
end
|
33
|
+
|
34
|
+
def is?(what)
|
35
|
+
event.to_s == what.to_s || super
|
36
|
+
end
|
37
|
+
|
38
|
+
alias_method :similar_to?, :==
|
39
|
+
end
|
40
|
+
end
|
data/lib/flv/file.rb
ADDED
@@ -0,0 +1,41 @@
|
|
1
|
+
module FLV
|
2
|
+
module File
|
3
|
+
def each(*arg, &block)
|
4
|
+
return super unless arg.empty?
|
5
|
+
return to_enum unless block_given?
|
6
|
+
h= read(Header)
|
7
|
+
yield h
|
8
|
+
super(Tag, &block)
|
9
|
+
end
|
10
|
+
|
11
|
+
def self.open(*arg)
|
12
|
+
file = ::File.open(*arg)
|
13
|
+
begin
|
14
|
+
file = return_value = file.packed.extend(File)
|
15
|
+
rescue Exception
|
16
|
+
file.close
|
17
|
+
raise
|
18
|
+
end
|
19
|
+
begin
|
20
|
+
return_value = yield(file)
|
21
|
+
ensure
|
22
|
+
file.close
|
23
|
+
end if block_given?
|
24
|
+
return_value
|
25
|
+
end
|
26
|
+
|
27
|
+
def self.read(portname, *arg)
|
28
|
+
open(portname) do |f|
|
29
|
+
return f.to_a if arg.empty?
|
30
|
+
n, offset = arg.first, arg[1] || 0
|
31
|
+
f.each.first(n+offset)[offset, offset+n-1]
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def self.foreach(portname, *, &block)
|
36
|
+
open(portname) do |f|
|
37
|
+
f.each(&block)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
data/lib/flv/header.rb
ADDED
@@ -0,0 +1,37 @@
|
|
1
|
+
module FLV
|
2
|
+
# Represents the header chunk present at the very beginning of any FLV file.
|
3
|
+
class Header
|
4
|
+
include Base
|
5
|
+
attr_accessor :version, :has_audio, :has_video, :extra, :path
|
6
|
+
|
7
|
+
FLV_SIGNATURE = [String, {:bytes => 3}].freeze
|
8
|
+
SIGNATURE = 'FLV'
|
9
|
+
MIN_OFFSET = 9
|
10
|
+
FLAGS = { :has_video => 1, :has_audio => 4 }.freeze
|
11
|
+
|
12
|
+
def read_packed(io, *) #:nodoc:
|
13
|
+
signature, self.version, type_flags, offset = \
|
14
|
+
io >> FLV_SIGNATURE >> :char >> :char >> :unsigned_long
|
15
|
+
|
16
|
+
raise RuntimeError.new("typeflags is #{signature}, #{version}, #{type_flags}, #{offset}") unless Fixnum === type_flags
|
17
|
+
|
18
|
+
FLAGS.each {|flag,mask| send("#{flag}=", type_flags & mask > 0) }
|
19
|
+
self.extra = io.read(offset - MIN_OFFSET)
|
20
|
+
ignore_PreviousTagSize0 = io.read :unsigned_long
|
21
|
+
self.path = io.try :path
|
22
|
+
raise IOError("Wrong Signature (#{signature} instead of #{SIGNATURE})") unless SIGNATURE == signature
|
23
|
+
end
|
24
|
+
|
25
|
+
def write_packed(io, *) #:nodoc:
|
26
|
+
self.extra ||= ""
|
27
|
+
type_flags = FLAGS.sum{|flag, mask | send(flag) ? mask : 0}
|
28
|
+
io << SIGNATURE << [self.version || 1, :char] << [type_flags, :char] <<
|
29
|
+
[MIN_OFFSET + self.extra.length, :unsigned_long] << self.extra << [0, :unsigned_long]
|
30
|
+
end
|
31
|
+
|
32
|
+
def debug(format, *)
|
33
|
+
format.header("Header", path)
|
34
|
+
format.values(to_h.tap{|h| h.delete(:path)})
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
data/lib/flv/packing.rb
ADDED
@@ -0,0 +1,140 @@
|
|
1
|
+
require 'singleton'
|
2
|
+
|
3
|
+
module FLV
|
4
|
+
class Event < Hash ; end
|
5
|
+
# FLV files can contain structured data. This modules makes it easy to (un)pack that data.
|
6
|
+
# The packing option +flv_value+ can (un)pack any kind of variable.
|
7
|
+
# It corresponds to +SCRIPTDATAVALUE+ in the official FLV file format spec.
|
8
|
+
# Implementation Note:
|
9
|
+
# +flv_value+ is actually simply a wrapper that will (un)pack the type of variable;
|
10
|
+
# the actual data is (un)packed with the format +flv+, which is independently defined for each type
|
11
|
+
module Packing
|
12
|
+
|
13
|
+
# A special kind of value used to signify the end of a list (implemented at the end)
|
14
|
+
class EndOfList # :nodoc:
|
15
|
+
end
|
16
|
+
|
17
|
+
#=== Top-level :flv_value filter:
|
18
|
+
|
19
|
+
TYPE_TO_CLASS = Hash.new do |h, type|
|
20
|
+
#EndOfList
|
21
|
+
raise IOError, "Invalid type for a flash variable. #{type.inspect} is not in #{h.keys.sort.inspect}" #todo: handle error for corrupted da
|
22
|
+
end.merge!(
|
23
|
+
0 => Numeric ,
|
24
|
+
1 => TrueClass , # There really should be a Boolean class!
|
25
|
+
2 => String ,
|
26
|
+
3 => Hash ,
|
27
|
+
8 => [Hash, :flv_with_size],
|
28
|
+
9 => EndOfList ,
|
29
|
+
10 => Array ,
|
30
|
+
11 => Time
|
31
|
+
).freeze
|
32
|
+
|
33
|
+
CLASS_TO_TYPE = Hash.new do |h, klass|
|
34
|
+
h[klass] = h[klass.superclass] # Makes it such that CLASS_TO_TYPE[Fixnum] = CLASS_TO_TYPE[Integer]
|
35
|
+
end.merge!(TYPE_TO_CLASS.invert).merge!(
|
36
|
+
Event => TYPE_TO_CLASS.key([Hash, :flv_with_size]), # Write Events as hashes with size
|
37
|
+
FalseClass => TYPE_TO_CLASS.key(TrueClass)
|
38
|
+
)
|
39
|
+
|
40
|
+
# Read/write the type and (un)pack the actual data with :flv
|
41
|
+
Object.packers.set(:flv_value) do |packer|
|
42
|
+
packer.write do |io|
|
43
|
+
type_nb = CLASS_TO_TYPE[self.class]
|
44
|
+
klass, format = TYPE_TO_CLASS[type_nb]
|
45
|
+
io << [type_nb, :char] << [self, format || :flv]
|
46
|
+
end
|
47
|
+
packer.read do |io|
|
48
|
+
klass, format = TYPE_TO_CLASS[io.read(:char)]
|
49
|
+
io.read([klass, format || :flv])
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
#=== For each basic type, the :flv filter (un)packs the actual data:
|
54
|
+
|
55
|
+
Numeric.packers.set(:flv) do |packer|
|
56
|
+
# Both Integers and Floats are packed as doubles
|
57
|
+
packer.write {|io| io << [to_f, :double] }
|
58
|
+
packer.read do |io|
|
59
|
+
n = io.read(:double)
|
60
|
+
n.to_i == n ? n.to_i : n
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
[TrueClass, FalseClass].each do |klass|
|
65
|
+
klass.packers.set(:flv) do |packer|
|
66
|
+
packer.write {|io| io << [self ? 1 : 0, :char] }
|
67
|
+
packer.read {|io| io.read(:char) != 0 }
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
String.packers.set(:flv) do |packer|
|
72
|
+
packer.write {|io| io << [length, :unsigned_short] << self }
|
73
|
+
packer.read {|io| io.read(io.read(:unsigned_short)) }
|
74
|
+
end
|
75
|
+
|
76
|
+
Array.packers.set(:flv) do |packer|
|
77
|
+
packer.write do |io|
|
78
|
+
io << [length, :unsigned_long]
|
79
|
+
each do |value|
|
80
|
+
io << [value, :flv_value]
|
81
|
+
end
|
82
|
+
end
|
83
|
+
packer.read do |io|
|
84
|
+
nb = io.read(:unsigned_long)
|
85
|
+
io.each(:flv_value).take(nb)
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
# The default format for hashes has a useless hint for the size
|
90
|
+
# This filter simply acts as a wrapper for the :flv_without_size filter
|
91
|
+
Hash.packers.set(:flv_with_size) do |packer|
|
92
|
+
packer.write do |io|
|
93
|
+
io << [length, :unsigned_long] << [self, :flv]
|
94
|
+
end
|
95
|
+
packer.read do |io|
|
96
|
+
ignore_length_hint = io >> :unsigned_long
|
97
|
+
io.read [Hash, :flv]
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
Hash.packers.set(:flv) do |packer|
|
102
|
+
packer.write do |io|
|
103
|
+
each do |key, value|
|
104
|
+
io << [key.to_s, :flv] << [value, :flv_value]
|
105
|
+
end
|
106
|
+
io << ["", :flv] << [EndOfList.instance, :flv_value]
|
107
|
+
end
|
108
|
+
packer.read do |io|
|
109
|
+
Hash[
|
110
|
+
io.each([String, :flv], :flv_value).
|
111
|
+
take_while {|str, val| val != EndOfList.instance }.
|
112
|
+
map{|k,v| [k.to_sym, v]}
|
113
|
+
]
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
Time.packers.set(:flv) do |packer|
|
118
|
+
packer.write {|io| io << [to_f * 1000, :double] << [Time.now.gmtoff / 60, :short] }
|
119
|
+
packer.read do |io|
|
120
|
+
seconds, zone = io >> :double >> :short
|
121
|
+
Time.at((seconds / 1000).to_i) + (zone * 60) - Time.now.gmtoff
|
122
|
+
end
|
123
|
+
end
|
124
|
+
|
125
|
+
class EndOfList # :nodoc:
|
126
|
+
include Singleton, Packable
|
127
|
+
packers.set :flv, {}
|
128
|
+
|
129
|
+
def write_packed(*)
|
130
|
+
# no data to write
|
131
|
+
end
|
132
|
+
|
133
|
+
def self.read_packed(*)
|
134
|
+
self.instance
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
Integer.packers.set :unsigned_24bits, :bytes => 3, :signed => false, :endian => :big
|
139
|
+
end #module Packing
|
140
|
+
end #module FLV
|
data/lib/flv/tag.rb
ADDED
@@ -0,0 +1,62 @@
|
|
1
|
+
module FLV
|
2
|
+
# FLV files consists of a single header and a collection of Tags.
|
3
|
+
# Tags all have a timestamp and a body; this body can be Audio, Video, or Event.
|
4
|
+
class Tag
|
5
|
+
include Base
|
6
|
+
attr_accessor :body, :timestamp
|
7
|
+
|
8
|
+
def timestamp=(t)
|
9
|
+
@timestamp = Timestamp.try_convert(t)
|
10
|
+
end
|
11
|
+
|
12
|
+
def initialize(timestamp = 0, body = nil)
|
13
|
+
self.body = body.instance_of?(Hash) ? Event.new(:onMetaData, body) : body
|
14
|
+
self.timestamp = timestamp
|
15
|
+
end
|
16
|
+
|
17
|
+
CLASS_CODE = {
|
18
|
+
8 => Audio,
|
19
|
+
9 => Video,
|
20
|
+
18 => Event
|
21
|
+
}.freeze
|
22
|
+
|
23
|
+
def write_packed(io, *) #:nodoc
|
24
|
+
packed_body = @body.pack
|
25
|
+
len = io.pos_change do
|
26
|
+
io << [CLASS_CODE.key(self.body.class), :char] \
|
27
|
+
<< [packed_body.length, :unsigned_24bits] \
|
28
|
+
<< [@timestamp.in_milliseconds, :unsigned_24bits] \
|
29
|
+
<< [@timestamp.in_milliseconds >>24, :char] \
|
30
|
+
<< [streamid=0, :unsigned_24bits] \
|
31
|
+
<< packed_body
|
32
|
+
end
|
33
|
+
io << [len, :unsigned_long]
|
34
|
+
end
|
35
|
+
|
36
|
+
def read_packed(io, options) #:nodoc
|
37
|
+
len = io.pos_change do
|
38
|
+
code, body_len, timestamp_in_ms, timestamp_in_ms_ext, streamid =
|
39
|
+
io >>:char >>:unsigned_24bits >>:unsigned_24bits >>:char >>:unsigned_24bits
|
40
|
+
@timestamp = Timestamp.in_milliseconds(timestamp_in_ms + (timestamp_in_ms_ext << 24))
|
41
|
+
@body = io.read CLASS_CODE[code] || Body, :bytes => body_len
|
42
|
+
end
|
43
|
+
FLV::Util.double_check :size, len, io.read(:unsigned_long) #todo
|
44
|
+
end
|
45
|
+
|
46
|
+
def debug(format, compared_with = nil) #:nodoc
|
47
|
+
format.header("#{timestamp} ", @body.title)
|
48
|
+
@body.debug(format) unless compared_with && @body.similar_to?(compared_with.body)
|
49
|
+
end
|
50
|
+
|
51
|
+
def is?(what) #:nodoc
|
52
|
+
super || body.is?(what)
|
53
|
+
end
|
54
|
+
|
55
|
+
def method_missing(*arg, &block)
|
56
|
+
super
|
57
|
+
rescue NoMethodError
|
58
|
+
body.send(*arg, &block)
|
59
|
+
end
|
60
|
+
|
61
|
+
end
|
62
|
+
end
|
@@ -0,0 +1,124 @@
|
|
1
|
+
require 'delegate'
|
2
|
+
require 'optparse'
|
3
|
+
|
4
|
+
module FLV
|
5
|
+
class Timestamp < DelegateClass(Float)
|
6
|
+
def initialize(ms=0)
|
7
|
+
raise ArgumentError unless ms.is_a? Numeric
|
8
|
+
super
|
9
|
+
end
|
10
|
+
|
11
|
+
# Returns [hours, minutes, seconds, milliseconds]
|
12
|
+
def to_a
|
13
|
+
n = in_milliseconds
|
14
|
+
[1000,60,60].map do |div|
|
15
|
+
val = n % div
|
16
|
+
n /= div
|
17
|
+
val
|
18
|
+
end.reverse.unshift(n)
|
19
|
+
end
|
20
|
+
|
21
|
+
# Returns "HH:MM:SS.MMMM" like "1:23:45.678" or "1:00.000" for 1 minute.
|
22
|
+
def to_s
|
23
|
+
return "" if self == INFINITY
|
24
|
+
nbs = to_a.drop_while(&:zero?)
|
25
|
+
ms = nbs.pop || 0
|
26
|
+
first = nbs.shift || 0
|
27
|
+
sprintf("%d%s.%03d", first, nbs.map{|n| sprintf(":%02d",n)}.join, ms)
|
28
|
+
end
|
29
|
+
|
30
|
+
def in_seconds
|
31
|
+
to_f
|
32
|
+
end
|
33
|
+
|
34
|
+
def in_milliseconds
|
35
|
+
(self * 1000).round
|
36
|
+
end
|
37
|
+
|
38
|
+
def widen(amount)
|
39
|
+
TimestampRange.new(self,self).widen(amount)
|
40
|
+
end
|
41
|
+
|
42
|
+
def self.in_milliseconds(ms)
|
43
|
+
Timestamp.new(ms/1000.0)
|
44
|
+
end
|
45
|
+
|
46
|
+
def self.in_seconds(s)
|
47
|
+
new s
|
48
|
+
end
|
49
|
+
|
50
|
+
def self.try_convert(s, if_empty_string = 0)
|
51
|
+
case s
|
52
|
+
when Timestamp
|
53
|
+
s
|
54
|
+
when Numeric
|
55
|
+
new s
|
56
|
+
when ""
|
57
|
+
new if_empty_string
|
58
|
+
when REGEXP
|
59
|
+
h, m, s, ms = Regexp.last_match.captures
|
60
|
+
ms &&= ms.ljust(3,'0')[0,3] # e.g. 1.23 => 1.230
|
61
|
+
h, m = m, h if h and h.end_with?(":") and m.nil?
|
62
|
+
h, m, s, ms = [h, m, s, ms].map{|n| (n || 0).to_i}
|
63
|
+
in_seconds ((h*60+m)*60+s)+ms/1000.0
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
REGEXP = /^(\d*[h:])?(\d*[m:])?(\d*)\.?(\d*)$/.freeze
|
68
|
+
end # Timestamp
|
69
|
+
|
70
|
+
INFINITY = 1/0.0
|
71
|
+
class TimestampRange < Range
|
72
|
+
core = Timestamp::REGEXP.source.gsub(/[\$\^\(\)]/,"")
|
73
|
+
REGEXP = Regexp.new("^(#{core})-(#{core})$").freeze
|
74
|
+
|
75
|
+
def to_s
|
76
|
+
"#{self.begin}-#{self.end}"
|
77
|
+
end
|
78
|
+
|
79
|
+
def initialize(from, to, exclusive=false)
|
80
|
+
super(Timestamp.try_convert(from), Timestamp.try_convert(to, INFINITY), exclusive)
|
81
|
+
end
|
82
|
+
|
83
|
+
def in_seconds
|
84
|
+
Range.new(self.begin.in_seconds, self.end.in_seconds, self.exclude_end?)
|
85
|
+
end
|
86
|
+
|
87
|
+
def in_milliseconds
|
88
|
+
Range.new(self.begin.in_milliseconds, self.end.in_milliseconds, self.exclude_end?)
|
89
|
+
end
|
90
|
+
|
91
|
+
def self.try_convert(s)
|
92
|
+
case s
|
93
|
+
when Range
|
94
|
+
new s.begin, s.end
|
95
|
+
when TimestampRange
|
96
|
+
s
|
97
|
+
when REGEXP
|
98
|
+
new *Regexp.last_match.captures
|
99
|
+
else
|
100
|
+
p "Can't convert #{s}"
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
def widen(amount)
|
105
|
+
TimestampRange.new [self.begin - amount, 0].max, self.end + amount, self.exclude_end?
|
106
|
+
end
|
107
|
+
end # TimestampRange
|
108
|
+
OptionParser.accept(TimestampRange, TimestampRange::REGEXP) {|str,from,to| TimestampRange.try_convert(str)}
|
109
|
+
|
110
|
+
|
111
|
+
class TimestampOrTimestampRange # :nodoc:
|
112
|
+
core = Timestamp::REGEXP.source.gsub(/[\$\^\(\)]/,"")
|
113
|
+
REGEXP = Regexp.new("^(#{core})-(#{core})|(#{core})$").freeze
|
114
|
+
end # TimestampOrTimestampRange
|
115
|
+
OptionParser.accept(TimestampOrTimestampRange, TimestampOrTimestampRange::REGEXP) do |str,from, to, from_to|
|
116
|
+
(from_to ? Timestamp : TimestampRange).try_convert(str)
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
class Range # :nodoc:
|
121
|
+
def ==(r) # Override built-in == because of bug in Ruby 1.8 & 1.9, see http://redmine.ruby-lang.org/issues/show/1165
|
122
|
+
self.begin == r.begin && self.end == r.end && self.exclude_end? == r.exclude_end?
|
123
|
+
end
|
124
|
+
end
|