Module: Metaclean::Ffmpeg

Defined in:
lib/metaclean/ffmpeg.rb

Constant Summary collapse

NESTED_IMAGE_CODECS =
%w[bmp gif mjpeg mjpegb png tiff webp].freeze

Class Method Summary collapse

Class Method Details

.append_safe_stream_languages(args, streams) ⇒ Object



113
114
115
116
117
118
119
120
121
122
123
124
# File 'lib/metaclean/ffmpeg.rb', line 113

def append_safe_stream_languages(args, streams)
  streams.each do |stream|
    index = Integer(stream.fetch('index'))
    tags = stream['tags'].is_a?(Hash) ? stream['tags'] : {}
    language = tags['language'].to_s.downcase
    if language.match?(/\A[a-z]{2,3}(?:-[a-z0-9]{2,8})*\z/)
      args.concat(["-metadata:s:#{index}", "language=#{language}"])
    end
  end
rescue KeyError, TypeError, ArgumentError => e
  raise Error, "Unexpected ffprobe stream data: #{e.message}"
end

.available?Boolean

Returns:

  • (Boolean)


11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# File 'lib/metaclean/ffmpeg.rb', line 11

def available?
  return @available if defined?(@available)

  out, _err, status = Metaclean.capture3(
    'ffmpeg', '-version',
    timeout: Metaclean::PROBE_TIMEOUT,
    max_output: Metaclean::PROBE_MAX_OUTPUT_BYTES
  )
  _probe_out, _probe_err, probe_status = Metaclean.capture3(
    'ffprobe', '-version',
    timeout: Metaclean::PROBE_TIMEOUT,
    max_output: Metaclean::PROBE_MAX_OUTPUT_BYTES
  )
  @available = status.success? && probe_status.success?
  @version = @available ? out.lines.first.to_s.split[2] : nil
  @available
rescue Errno::ENOENT, Error
  @version = nil
  @available = false
end

.ensure_structure_preserved!(before, after) ⇒ Object



101
102
103
104
105
106
107
# File 'lib/metaclean/ffmpeg.rb', line 101

def ensure_structure_preserved!(before, after)
  before_signatures = stream_signatures(before[:streams])
  after_signatures = stream_signatures(after[:streams])
  unless before_signatures == after_signatures && before[:chapters] == after[:chapters]
    raise Error, 'ffmpeg output changed the stream or chapter structure'
  end
end

.file_url(path) ⇒ Object



126
127
128
# File 'lib/metaclean/ffmpeg.rb', line 126

def file_url(path)
  "file:#{File.expand_path(path)}"
end

.probe_structure(path) ⇒ Object



65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
# File 'lib/metaclean/ffmpeg.rb', line 65

def probe_structure(path)
  out, err, status = Metaclean.capture3(
    'ffprobe', '-v', 'error', '-show_streams', '-show_chapters',
    '-of', 'json', file_url(path)
  )
  raise Error, "ffprobe failed: #{err.strip}" unless status.success?

  data = JSON.parse(out)
  streams = data.fetch('streams', [])
  chapters = data.fetch('chapters', [])
  raise TypeError unless streams.is_a?(Array) && chapters.is_a?(Array)

  { streams: streams, chapters: chapters.size }
rescue JSON::ParserError, KeyError, TypeError => e
  raise Error, "Could not parse ffprobe output: #{e.message}"
end

.reject_unverifiable_streams!(streams) ⇒ Object

Raises:



82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
# File 'lib/metaclean/ffmpeg.rb', line 82

def reject_unverifiable_streams!(streams)
  video_streams = streams.count { |stream| stream['codec_type'] == 'video' }
  nested = streams.any? do |stream|
    tags = stream['tags'].is_a?(Hash) ? stream['tags'] : {}
    disposition = stream['disposition'].is_a?(Hash) ? stream['disposition'] : {}
    nested_image = stream['codec_type'] == 'video' &&
                   NESTED_IMAGE_CODECS.include?(stream['codec_name']) &&
                   (video_streams > 1 || streams.any? { |candidate| candidate['codec_type'] == 'audio' })
    stream['codec_type'] == 'attachment' ||
      nested_image ||
      disposition['attached_pic'].to_i == 1 ||
      !tags['filename'].to_s.empty? ||
      !tags['mimetype'].to_s.empty?
  end
  return unless nested

  raise Error, 'Matroska contains attachments or cover art whose nested metadata cannot be verified'
end

.stream_signatures(streams) ⇒ Object



109
110
111
# File 'lib/metaclean/ffmpeg.rb', line 109

def stream_signatures(streams)
  streams.map { |stream| [stream['codec_type'], stream['codec_name']] }
end

.strip!(path) ⇒ Object

Raises:



36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
# File 'lib/metaclean/ffmpeg.rb', line 36

def strip!(path)
  raise Error, 'ffmpeg not available' unless available?

  source = FileOps.regular_stat!(path)
  structure = probe_structure(path)
  reject_unverifiable_streams!(structure[:streams])
  FileOps.with_private_workspace(path, 'ffmpeg') do |workspace|
    tmp = File.join(workspace, "output#{File.extname(path)}")
    args = [
      'ffmpeg', '-y', '-v', 'error', '-nostdin', '-i', file_url(path),
      '-map', '0', '-map_metadata', '-1',
      '-map_chapters', structure[:chapters].positive? ? '0' : '-1'
    ]
    args.concat(['-metadata:c', 'title=']) if structure[:chapters].positive?
    append_safe_stream_languages(args, structure[:streams])
    args.concat(['-c', 'copy', file_url(tmp)])

    _out, err, status = Metaclean.capture3(*args)
    raise Error, "ffmpeg failed: #{err.strip}" unless status.success? && File.exist?(tmp)

    FileOps.validate_output!(tmp)
    ensure_structure_preserved!(structure, probe_structure(tmp))
    FileOps.ensure_same_file!(path, source)
    FileOps.(tmp, source)
    File.rename(tmp, path)
    true
  end
end

.versionObject



32
33
34
# File 'lib/metaclean/ffmpeg.rb', line 32

def version
  available? ? @version : nil
end