hiiro 0.1.81 → 0.1.82
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.
- checksums.yaml +4 -4
- data/bin/h-pr +115 -8
- data/lib/hiiro/version.rb +1 -1
- metadata +1 -8
- data/bin/h-home +0 -62
- data/bin/h-html +0 -380
- data/bin/h-mic +0 -93
- data/bin/h-note +0 -119
- data/bin/h-remind +0 -614
- data/bin/h-serve +0 -6
- data/bin/h-video +0 -523
data/bin/h-video
DELETED
|
@@ -1,523 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env ruby
|
|
2
|
-
|
|
3
|
-
require 'hiiro'
|
|
4
|
-
|
|
5
|
-
o = Hiiro.init(*ARGV)
|
|
6
|
-
|
|
7
|
-
# Helper for generating output filenames
|
|
8
|
-
def output_name(infile, suffix, new_ext = nil)
|
|
9
|
-
ext = File.extname(infile)
|
|
10
|
-
basename = File.basename(infile, ext)
|
|
11
|
-
new_ext ||= ext
|
|
12
|
-
new_ext = '.' + new_ext unless new_ext.start_with?('.')
|
|
13
|
-
"#{basename}.#{suffix}#{new_ext}"
|
|
14
|
-
end
|
|
15
|
-
|
|
16
|
-
# === INFO / INSPECTION ===
|
|
17
|
-
|
|
18
|
-
o.add_subcmd(:info) { |ifile|
|
|
19
|
-
raise 'Missing required argument: input_file' if ifile.nil? || ifile.strip == ''
|
|
20
|
-
require 'json'
|
|
21
|
-
require 'open3'
|
|
22
|
-
|
|
23
|
-
json, status = Open3.capture2('ffprobe', '-v', 'error', '-print_format', 'json', '-show_format', '-show_streams', ifile)
|
|
24
|
-
raise "ffprobe failed: #{status}" unless status.success?
|
|
25
|
-
|
|
26
|
-
data = JSON.parse(json)
|
|
27
|
-
format = data['format'] || {}
|
|
28
|
-
streams = data['streams'] || []
|
|
29
|
-
|
|
30
|
-
# Format file size nicely
|
|
31
|
-
format_size = ->(bytes) {
|
|
32
|
-
return 'unknown' unless bytes
|
|
33
|
-
bytes = bytes.to_f
|
|
34
|
-
units = ['B', 'KB', 'MB', 'GB']
|
|
35
|
-
unit = 0
|
|
36
|
-
while bytes >= 1024 && unit < units.length - 1
|
|
37
|
-
bytes /= 1024
|
|
38
|
-
unit += 1
|
|
39
|
-
end
|
|
40
|
-
"%.2f %s" % [bytes, units[unit]]
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
# Format duration nicely
|
|
44
|
-
format_duration = ->(seconds) {
|
|
45
|
-
return 'unknown' unless seconds
|
|
46
|
-
seconds = seconds.to_f
|
|
47
|
-
hours = (seconds / 3600).to_i
|
|
48
|
-
minutes = ((seconds % 3600) / 60).to_i
|
|
49
|
-
secs = (seconds % 60).to_i
|
|
50
|
-
ms = ((seconds % 1) * 1000).to_i
|
|
51
|
-
if hours > 0
|
|
52
|
-
"%d:%02d:%02d.%03d" % [hours, minutes, secs, ms]
|
|
53
|
-
else
|
|
54
|
-
"%02d:%02d.%03d" % [minutes, secs, ms]
|
|
55
|
-
end
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
# Format bitrate nicely
|
|
59
|
-
format_bitrate = ->(bps) {
|
|
60
|
-
return nil unless bps
|
|
61
|
-
bps = bps.to_f
|
|
62
|
-
if bps >= 1_000_000
|
|
63
|
-
"%.2f Mbps" % (bps / 1_000_000)
|
|
64
|
-
else
|
|
65
|
-
"%.0f kbps" % (bps / 1000)
|
|
66
|
-
end
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
puts "=" * 60
|
|
70
|
-
puts "FILE: #{File.basename(ifile)}"
|
|
71
|
-
puts "=" * 60
|
|
72
|
-
puts "Format: #{format['format_long_name'] || format['format_name'] || 'unknown'}"
|
|
73
|
-
puts "Duration: #{format_duration.call(format['duration'])}"
|
|
74
|
-
puts "Size: #{format_size.call(format['size'])}"
|
|
75
|
-
puts "Bitrate: #{format_bitrate.call(format['bit_rate']) || 'unknown'}"
|
|
76
|
-
|
|
77
|
-
video_streams = streams.select { |s| s['codec_type'] == 'video' }
|
|
78
|
-
audio_streams = streams.select { |s| s['codec_type'] == 'audio' }
|
|
79
|
-
sub_streams = streams.select { |s| s['codec_type'] == 'subtitle' }
|
|
80
|
-
|
|
81
|
-
video_streams.each_with_index do |v, i|
|
|
82
|
-
puts "-" * 60
|
|
83
|
-
puts "VIDEO #{i}: #{v['codec_long_name'] || v['codec_name']}"
|
|
84
|
-
puts " Resolution: #{v['width']}x#{v['height']}"
|
|
85
|
-
if v['display_aspect_ratio']
|
|
86
|
-
puts " Aspect: #{v['display_aspect_ratio']}"
|
|
87
|
-
end
|
|
88
|
-
if v['r_frame_rate']
|
|
89
|
-
num, den = v['r_frame_rate'].split('/').map(&:to_f)
|
|
90
|
-
fps = den > 0 ? (num / den).round(2) : 0
|
|
91
|
-
puts " FPS: #{fps}"
|
|
92
|
-
end
|
|
93
|
-
if v['bit_rate']
|
|
94
|
-
puts " Bitrate: #{format_bitrate.call(v['bit_rate'])}"
|
|
95
|
-
end
|
|
96
|
-
if v['pix_fmt']
|
|
97
|
-
puts " Pixel fmt: #{v['pix_fmt']}"
|
|
98
|
-
end
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
audio_streams.each_with_index do |a, i|
|
|
102
|
-
puts "-" * 60
|
|
103
|
-
lang = a.dig('tags', 'language')
|
|
104
|
-
title = a.dig('tags', 'title')
|
|
105
|
-
label = [lang, title].compact.join(' - ')
|
|
106
|
-
label = label.empty? ? '' : " (#{label})"
|
|
107
|
-
puts "AUDIO #{i}:#{label} #{a['codec_long_name'] || a['codec_name']}"
|
|
108
|
-
if a['channels']
|
|
109
|
-
channel_layout = a['channel_layout'] || "#{a['channels']}ch"
|
|
110
|
-
puts " Channels: #{channel_layout}"
|
|
111
|
-
end
|
|
112
|
-
if a['sample_rate']
|
|
113
|
-
puts " Sample rate: #{a['sample_rate']} Hz"
|
|
114
|
-
end
|
|
115
|
-
if a['bit_rate']
|
|
116
|
-
puts " Bitrate: #{format_bitrate.call(a['bit_rate'])}"
|
|
117
|
-
end
|
|
118
|
-
end
|
|
119
|
-
|
|
120
|
-
if sub_streams.any?
|
|
121
|
-
puts "-" * 60
|
|
122
|
-
puts "SUBTITLES:"
|
|
123
|
-
sub_streams.each_with_index do |s, i|
|
|
124
|
-
lang = s.dig('tags', 'language') || 'unknown'
|
|
125
|
-
title = s.dig('tags', 'title')
|
|
126
|
-
codec = s['codec_name']
|
|
127
|
-
label = title ? "#{lang} - #{title}" : lang
|
|
128
|
-
puts " #{i}: [#{codec}] #{label}"
|
|
129
|
-
end
|
|
130
|
-
end
|
|
131
|
-
|
|
132
|
-
puts "=" * 60
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
o.add_subcmd(:streams) { |ifile|
|
|
136
|
-
raise 'Missing required argument: input_file' if ifile.nil? || ifile.strip == ''
|
|
137
|
-
system('ffprobe', '-v', 'error', '-show_entries', 'stream=index,codec_type,codec_name,width,height,duration,bit_rate', '-of', 'default=noprint_wrappers=1', ifile)
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
o.add_subcmd(:duration) { |ifile|
|
|
141
|
-
raise 'Missing required argument: input_file' if ifile.nil? || ifile.strip == ''
|
|
142
|
-
system('ffprobe', '-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', ifile)
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
# === RESIZING ===
|
|
146
|
-
|
|
147
|
-
o.add_subcmd(:resize) { |infile, scale, outfile|
|
|
148
|
-
raise 'Missing required argument: input_file' if infile.nil? || infile.strip == ''
|
|
149
|
-
scale ||= '720'
|
|
150
|
-
outfile ||= output_name(infile, "#{scale}p")
|
|
151
|
-
|
|
152
|
-
dimensions = ['-2', scale].join(':')
|
|
153
|
-
scale_arg = [:scale, dimensions].join(?=)
|
|
154
|
-
system('ffmpeg', '-i', infile, '-vf', scale_arg, outfile)
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
o.add_subcmd(:resize720) { |infile, outfile|
|
|
158
|
-
raise 'Missing required argument: input_file' if infile.nil? || infile.strip == ''
|
|
159
|
-
outfile ||= output_name(infile, '720p')
|
|
160
|
-
system('ffmpeg', '-i', infile, '-vf', 'scale=-2:720', outfile)
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
o.add_subcmd(:resize1080) { |infile, outfile|
|
|
164
|
-
raise 'Missing required argument: input_file' if infile.nil? || infile.strip == ''
|
|
165
|
-
outfile ||= output_name(infile, '1080p')
|
|
166
|
-
system('ffmpeg', '-i', infile, '-vf', 'scale=-2:1080', outfile)
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
# === FORMAT CONVERSION ===
|
|
170
|
-
|
|
171
|
-
o.add_subcmd(:convert) { |infile, format, outfile|
|
|
172
|
-
raise 'Missing required argument: input_file' if infile.nil? || infile.strip == ''
|
|
173
|
-
raise 'Missing required argument: format (e.g., mp4, mkv, avi, webm)' if format.nil? || format.strip == ''
|
|
174
|
-
outfile ||= output_name(infile, 'converted', format)
|
|
175
|
-
system('ffmpeg', '-i', infile, outfile)
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
o.add_subcmd(:to_mp4) { |infile, outfile|
|
|
179
|
-
raise 'Missing required argument: input_file' if infile.nil? || infile.strip == ''
|
|
180
|
-
outfile ||= output_name(infile, 'converted', 'mp4')
|
|
181
|
-
system('ffmpeg', '-i', infile, '-c:v', 'libx264', '-c:a', 'aac', outfile)
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
o.add_subcmd(:to_webm) { |infile, outfile|
|
|
185
|
-
raise 'Missing required argument: input_file' if infile.nil? || infile.strip == ''
|
|
186
|
-
outfile ||= output_name(infile, 'converted', 'webm')
|
|
187
|
-
system('ffmpeg', '-i', infile, '-c:v', 'libvpx-vp9', '-c:a', 'libopus', outfile)
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
o.add_subcmd(:to_mkv) { |infile, outfile|
|
|
191
|
-
raise 'Missing required argument: input_file' if infile.nil? || infile.strip == ''
|
|
192
|
-
outfile ||= output_name(infile, 'converted', 'mkv')
|
|
193
|
-
system('ffmpeg', '-i', infile, '-c', 'copy', outfile)
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
# === AUDIO EXTRACTION ===
|
|
197
|
-
|
|
198
|
-
o.add_subcmd(:audio) { |infile, outfile|
|
|
199
|
-
raise 'Missing required argument: input_file' if infile.nil? || infile.strip == ''
|
|
200
|
-
outfile ||= output_name(infile, 'audio', 'mp3')
|
|
201
|
-
system('ffmpeg', '-i', infile, '-vn', '-acodec', 'libmp3lame', '-q:a', '2', outfile)
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
o.add_subcmd(:audio_wav) { |infile, outfile|
|
|
205
|
-
raise 'Missing required argument: input_file' if infile.nil? || infile.strip == ''
|
|
206
|
-
outfile ||= output_name(infile, 'audio', 'wav')
|
|
207
|
-
system('ffmpeg', '-i', infile, '-vn', outfile)
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
o.add_subcmd(:audio_aac) { |infile, outfile|
|
|
211
|
-
raise 'Missing required argument: input_file' if infile.nil? || infile.strip == ''
|
|
212
|
-
outfile ||= output_name(infile, 'audio', 'aac')
|
|
213
|
-
system('ffmpeg', '-i', infile, '-vn', '-c:a', 'aac', '-b:a', '192k', outfile)
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
o.add_subcmd(:audio_flac) { |infile, outfile|
|
|
217
|
-
raise 'Missing required argument: input_file' if infile.nil? || infile.strip == ''
|
|
218
|
-
outfile ||= output_name(infile, 'audio', 'flac')
|
|
219
|
-
system('ffmpeg', '-i', infile, '-vn', '-c:a', 'flac', outfile)
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
# === CLIP EXTRACTION ===
|
|
223
|
-
|
|
224
|
-
# Usage: h-video clip input.mp4 00:01:30 60 [output.mp4]
|
|
225
|
-
# Extracts 60 seconds starting at 1:30
|
|
226
|
-
o.add_subcmd(:clip) { |infile, start_time, duration, outfile|
|
|
227
|
-
raise 'Missing required argument: input_file' if infile.nil? || infile.strip == ''
|
|
228
|
-
raise 'Missing required argument: start_time (e.g., 00:01:30 or 90)' if start_time.nil? || start_time.strip == ''
|
|
229
|
-
raise 'Missing required argument: duration (seconds or HH:MM:SS)' if duration.nil? || duration.strip == ''
|
|
230
|
-
outfile ||= output_name(infile, "clip_#{start_time.gsub(':', '-')}")
|
|
231
|
-
system('ffmpeg', '-i', infile, '-ss', start_time, '-t', duration, '-c', 'copy', outfile)
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
# Usage: h-video clip_to input.mp4 00:01:30 00:02:30 [output.mp4]
|
|
235
|
-
# Extracts from 1:30 to 2:30
|
|
236
|
-
o.add_subcmd(:clip_to) { |infile, start_time, end_time, outfile|
|
|
237
|
-
raise 'Missing required argument: input_file' if infile.nil? || infile.strip == ''
|
|
238
|
-
raise 'Missing required argument: start_time (e.g., 00:01:30 or 90)' if start_time.nil? || start_time.strip == ''
|
|
239
|
-
raise 'Missing required argument: end_time (e.g., 00:02:30 or 150)' if end_time.nil? || end_time.strip == ''
|
|
240
|
-
outfile ||= output_name(infile, "clip_#{start_time.gsub(':', '-')}_to_#{end_time.gsub(':', '-')}")
|
|
241
|
-
system('ffmpeg', '-i', infile, '-ss', start_time, '-to', end_time, '-c', 'copy', outfile)
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
# Re-encode clip (slower but more accurate cuts)
|
|
245
|
-
o.add_subcmd(:clip_precise) { |infile, start_time, duration, outfile|
|
|
246
|
-
raise 'Missing required argument: input_file' if infile.nil? || infile.strip == ''
|
|
247
|
-
raise 'Missing required argument: start_time' if start_time.nil? || start_time.strip == ''
|
|
248
|
-
raise 'Missing required argument: duration' if duration.nil? || duration.strip == ''
|
|
249
|
-
outfile ||= output_name(infile, "clip_precise_#{start_time.gsub(':', '-')}")
|
|
250
|
-
system('ffmpeg', '-i', infile, '-ss', start_time, '-t', duration, outfile)
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
# === SUBTITLE EXTRACTION ===
|
|
254
|
-
|
|
255
|
-
o.add_subcmd(:subs) { |infile, stream_index, outfile|
|
|
256
|
-
raise 'Missing required argument: input_file' if infile.nil? || infile.strip == ''
|
|
257
|
-
stream_index ||= '0'
|
|
258
|
-
outfile ||= output_name(infile, "subs_#{stream_index}", 'srt')
|
|
259
|
-
system('ffmpeg', '-i', infile, '-map', "0:s:#{stream_index}", outfile)
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
o.add_subcmd(:subs_all) { |infile|
|
|
263
|
-
raise 'Missing required argument: input_file' if infile.nil? || infile.strip == ''
|
|
264
|
-
basename = File.basename(infile, File.extname(infile))
|
|
265
|
-
system('ffmpeg', '-i', infile, '-map', '0:s', "#{basename}.subs.%d.srt")
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
o.add_subcmd(:list_subs) { |infile|
|
|
269
|
-
raise 'Missing required argument: input_file' if infile.nil? || infile.strip == ''
|
|
270
|
-
system('ffprobe', '-v', 'error', '-select_streams', 's', '-show_entries', 'stream=index,codec_name:stream_tags=language,title', '-of', 'default=noprint_wrappers=1', infile)
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
# === IMAGE/THUMBNAIL EXTRACTION ===
|
|
274
|
-
|
|
275
|
-
o.add_subcmd(:thumbnail) { |infile, timestamp, outfile|
|
|
276
|
-
raise 'Missing required argument: input_file' if infile.nil? || infile.strip == ''
|
|
277
|
-
timestamp ||= '00:00:01'
|
|
278
|
-
outfile ||= output_name(infile, "thumb_#{timestamp.gsub(':', '-')}", 'jpg')
|
|
279
|
-
system('ffmpeg', '-i', infile, '-ss', timestamp, '-vframes', '1', outfile)
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
o.add_subcmd(:thumbnails) { |infile, interval, outfile_pattern|
|
|
283
|
-
raise 'Missing required argument: input_file' if infile.nil? || infile.strip == ''
|
|
284
|
-
interval ||= '10'
|
|
285
|
-
basename = File.basename(infile, File.extname(infile))
|
|
286
|
-
outfile_pattern ||= "#{basename}.thumb.%04d.jpg"
|
|
287
|
-
system('ffmpeg', '-i', infile, '-vf', "fps=1/#{interval}", outfile_pattern)
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
# === GIF CREATION ===
|
|
291
|
-
|
|
292
|
-
o.add_subcmd(:gif) { |infile, start_time, duration, outfile|
|
|
293
|
-
raise 'Missing required argument: input_file' if infile.nil? || infile.strip == ''
|
|
294
|
-
outfile ||= output_name(infile, 'animated', 'gif')
|
|
295
|
-
|
|
296
|
-
args = ['ffmpeg', '-i', infile]
|
|
297
|
-
args += ['-ss', start_time] if start_time && start_time.strip != ''
|
|
298
|
-
args += ['-t', duration] if duration && duration.strip != ''
|
|
299
|
-
args += ['-vf', 'fps=10,scale=480:-1:flags=lanczos', '-loop', '0', outfile]
|
|
300
|
-
system(*args)
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
o.add_subcmd(:gif_hq) { |infile, start_time, duration, outfile|
|
|
304
|
-
raise 'Missing required argument: input_file' if infile.nil? || infile.strip == ''
|
|
305
|
-
outfile ||= output_name(infile, 'animated_hq', 'gif')
|
|
306
|
-
palette = "/tmp/palette_#{$$}.png"
|
|
307
|
-
|
|
308
|
-
filters = 'fps=15,scale=640:-1:flags=lanczos'
|
|
309
|
-
time_args = []
|
|
310
|
-
time_args += ['-ss', start_time] if start_time && start_time.strip != ''
|
|
311
|
-
time_args += ['-t', duration] if duration && duration.strip != ''
|
|
312
|
-
|
|
313
|
-
# Generate palette
|
|
314
|
-
system('ffmpeg', '-i', infile, *time_args, '-vf', "#{filters},palettegen", '-y', palette)
|
|
315
|
-
# Create GIF using palette
|
|
316
|
-
system('ffmpeg', '-i', infile, '-i', palette, *time_args, '-lavfi', "#{filters} [x]; [x][1:v] paletteuse", '-y', outfile)
|
|
317
|
-
File.delete(palette) if File.exist?(palette)
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
# === AUDIO MANIPULATION ===
|
|
321
|
-
|
|
322
|
-
o.add_subcmd(:mute) { |infile, outfile|
|
|
323
|
-
raise 'Missing required argument: input_file' if infile.nil? || infile.strip == ''
|
|
324
|
-
outfile ||= output_name(infile, 'muted')
|
|
325
|
-
system('ffmpeg', '-i', infile, '-c:v', 'copy', '-an', outfile)
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
o.add_subcmd(:replace_audio) { |video_file, audio_file, outfile|
|
|
329
|
-
raise 'Missing required argument: video_file' if video_file.nil? || video_file.strip == ''
|
|
330
|
-
raise 'Missing required argument: audio_file' if audio_file.nil? || audio_file.strip == ''
|
|
331
|
-
outfile ||= output_name(video_file, 'new_audio')
|
|
332
|
-
system('ffmpeg', '-i', video_file, '-i', audio_file, '-c:v', 'copy', '-map', '0:v:0', '-map', '1:a:0', outfile)
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
o.add_subcmd(:volume) { |infile, level, outfile|
|
|
336
|
-
raise 'Missing required argument: input_file' if infile.nil? || infile.strip == ''
|
|
337
|
-
raise 'Missing required argument: level (e.g., 2.0 for 2x, 0.5 for half)' if level.nil? || level.strip == ''
|
|
338
|
-
outfile ||= output_name(infile, "vol_#{level}")
|
|
339
|
-
system('ffmpeg', '-i', infile, '-filter:a', "volume=#{level}", '-c:v', 'copy', outfile)
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
# === SPEED / TEMPO ===
|
|
343
|
-
|
|
344
|
-
o.add_subcmd(:speed) { |infile, factor, outfile|
|
|
345
|
-
raise 'Missing required argument: input_file' if infile.nil? || infile.strip == ''
|
|
346
|
-
raise 'Missing required argument: factor (e.g., 2.0 for 2x speed, 0.5 for half speed)' if factor.nil? || factor.strip == ''
|
|
347
|
-
outfile ||= output_name(infile, "speed_#{factor}x")
|
|
348
|
-
video_speed = 1.0 / factor.to_f
|
|
349
|
-
audio_speed = factor.to_f
|
|
350
|
-
system('ffmpeg', '-i', infile, '-filter_complex', "[0:v]setpts=#{video_speed}*PTS[v];[0:a]atempo=#{audio_speed}[a]", '-map', '[v]', '-map', '[a]', outfile)
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
# === COMPRESSION ===
|
|
354
|
-
|
|
355
|
-
o.add_subcmd(:compress) { |infile, crf, outfile|
|
|
356
|
-
raise 'Missing required argument: input_file' if infile.nil? || infile.strip == ''
|
|
357
|
-
crf ||= '28' # Higher = more compression, lower quality. 18-28 is reasonable.
|
|
358
|
-
outfile ||= output_name(infile, "compressed_crf#{crf}")
|
|
359
|
-
system('ffmpeg', '-i', infile, '-c:v', 'libx264', '-crf', crf, '-preset', 'medium', '-c:a', 'aac', '-b:a', '128k', outfile)
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
o.add_subcmd(:compress_small) { |infile, outfile|
|
|
363
|
-
raise 'Missing required argument: input_file' if infile.nil? || infile.strip == ''
|
|
364
|
-
outfile ||= output_name(infile, 'small')
|
|
365
|
-
system('ffmpeg', '-i', infile, '-c:v', 'libx264', '-crf', '32', '-preset', 'slower', '-c:a', 'aac', '-b:a', '96k', '-vf', 'scale=-2:480', outfile)
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
# === ROTATION / TRANSFORMATION ===
|
|
369
|
-
|
|
370
|
-
o.add_subcmd(:rotate) { |infile, direction, outfile|
|
|
371
|
-
raise 'Missing required argument: input_file' if infile.nil? || infile.strip == ''
|
|
372
|
-
direction ||= 'cw' # cw, ccw, 180
|
|
373
|
-
|
|
374
|
-
transpose = case direction
|
|
375
|
-
when 'cw', 'clockwise', '90' then '1'
|
|
376
|
-
when 'ccw', 'counterclockwise', '-90', '270' then '2'
|
|
377
|
-
when '180' then '2,transpose=2'
|
|
378
|
-
else '1'
|
|
379
|
-
end
|
|
380
|
-
|
|
381
|
-
outfile ||= output_name(infile, "rotated_#{direction}")
|
|
382
|
-
system('ffmpeg', '-i', infile, '-vf', "transpose=#{transpose}", outfile)
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
o.add_subcmd(:flip_h) { |infile, outfile|
|
|
386
|
-
raise 'Missing required argument: input_file' if infile.nil? || infile.strip == ''
|
|
387
|
-
outfile ||= output_name(infile, 'flipped_h')
|
|
388
|
-
system('ffmpeg', '-i', infile, '-vf', 'hflip', '-c:a', 'copy', outfile)
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
o.add_subcmd(:flip_v) { |infile, outfile|
|
|
392
|
-
raise 'Missing required argument: input_file' if infile.nil? || infile.strip == ''
|
|
393
|
-
outfile ||= output_name(infile, 'flipped_v')
|
|
394
|
-
system('ffmpeg', '-i', infile, '-vf', 'vflip', '-c:a', 'copy', outfile)
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
# === CROPPING ===
|
|
398
|
-
|
|
399
|
-
# Usage: h-video crop input.mp4 640:480:100:50 [output.mp4]
|
|
400
|
-
# Crops to 640x480 starting at x=100, y=50
|
|
401
|
-
o.add_subcmd(:crop) { |infile, crop_params, outfile|
|
|
402
|
-
raise 'Missing required argument: input_file' if infile.nil? || infile.strip == ''
|
|
403
|
-
raise 'Missing required argument: crop_params (w:h:x:y, e.g., 640:480:100:50)' if crop_params.nil? || crop_params.strip == ''
|
|
404
|
-
outfile ||= output_name(infile, 'cropped')
|
|
405
|
-
system('ffmpeg', '-i', infile, '-vf', "crop=#{crop_params}", outfile)
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
# === CONCATENATION ===
|
|
409
|
-
|
|
410
|
-
# Usage: h-video concat file1.mp4 file2.mp4 file3.mp4 output.mp4
|
|
411
|
-
o.add_subcmd(:concat) { |*args|
|
|
412
|
-
raise 'Need at least 2 input files and 1 output file' if args.length < 3
|
|
413
|
-
outfile = args.pop
|
|
414
|
-
infiles = args
|
|
415
|
-
|
|
416
|
-
# Create concat file
|
|
417
|
-
concat_file = "/tmp/concat_#{$$}.txt"
|
|
418
|
-
File.open(concat_file, 'w') do |f|
|
|
419
|
-
infiles.each { |file| f.puts "file '#{File.expand_path(file)}'" }
|
|
420
|
-
end
|
|
421
|
-
|
|
422
|
-
system('ffmpeg', '-f', 'concat', '-safe', '0', '-i', concat_file, '-c', 'copy', outfile)
|
|
423
|
-
File.delete(concat_file) if File.exist?(concat_file)
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
# === FRAME RATE ===
|
|
427
|
-
|
|
428
|
-
o.add_subcmd(:fps) { |infile, rate, outfile|
|
|
429
|
-
raise 'Missing required argument: input_file' if infile.nil? || infile.strip == ''
|
|
430
|
-
raise 'Missing required argument: rate (e.g., 30, 24, 60)' if rate.nil? || rate.strip == ''
|
|
431
|
-
outfile ||= output_name(infile, "#{rate}fps")
|
|
432
|
-
system('ffmpeg', '-i', infile, '-filter:v', "fps=#{rate}", outfile)
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
# === METADATA ===
|
|
436
|
-
|
|
437
|
-
o.add_subcmd(:metadata) { |infile|
|
|
438
|
-
raise 'Missing required argument: input_file' if infile.nil? || infile.strip == ''
|
|
439
|
-
system('ffprobe', '-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', infile)
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
o.add_subcmd(:strip_metadata) { |infile, outfile|
|
|
443
|
-
raise 'Missing required argument: input_file' if infile.nil? || infile.strip == ''
|
|
444
|
-
outfile ||= output_name(infile, 'clean')
|
|
445
|
-
system('ffmpeg', '-i', infile, '-map_metadata', '-1', '-c', 'copy', outfile)
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
# === HELP ===
|
|
449
|
-
|
|
450
|
-
o.add_subcmd(:help) { |*args|
|
|
451
|
-
puts <<~HELP
|
|
452
|
-
h-video - FFmpeg wrapper for common video operations
|
|
453
|
-
|
|
454
|
-
INFO / INSPECTION:
|
|
455
|
-
info <file> Human-readable video summary
|
|
456
|
-
streams <file> List all streams (video, audio, subs)
|
|
457
|
-
duration <file> Get video duration
|
|
458
|
-
metadata <file> Show metadata as JSON
|
|
459
|
-
list_subs <file> List subtitle tracks
|
|
460
|
-
|
|
461
|
-
RESIZING:
|
|
462
|
-
resize <file> [height] [out] Resize to height (default 720)
|
|
463
|
-
resize720 <file> [out] Resize to 720p
|
|
464
|
-
resize1080 <file> [out] Resize to 1080p
|
|
465
|
-
|
|
466
|
-
FORMAT CONVERSION:
|
|
467
|
-
convert <file> <format> [out] Convert to format (mp4, mkv, webm, etc.)
|
|
468
|
-
to_mp4 <file> [out] Convert to MP4 (H.264/AAC)
|
|
469
|
-
to_webm <file> [out] Convert to WebM (VP9/Opus)
|
|
470
|
-
to_mkv <file> [out] Remux to MKV container
|
|
471
|
-
|
|
472
|
-
AUDIO:
|
|
473
|
-
audio <file> [out] Extract audio as MP3
|
|
474
|
-
audio_wav <file> [out] Extract audio as WAV
|
|
475
|
-
audio_aac <file> [out] Extract audio as AAC
|
|
476
|
-
audio_flac <file> [out] Extract audio as FLAC
|
|
477
|
-
mute <file> [out] Remove audio track
|
|
478
|
-
replace_audio <video> <audio> [out] Replace audio track
|
|
479
|
-
volume <file> <level> [out] Adjust volume (2.0 = 2x, 0.5 = half)
|
|
480
|
-
|
|
481
|
-
CLIPPING:
|
|
482
|
-
clip <file> <start> <duration> [out] Extract clip by duration
|
|
483
|
-
clip_to <file> <start> <end> [out] Extract clip by end time
|
|
484
|
-
clip_precise <file> <start> <dur> [out] Re-encoded clip (more accurate)
|
|
485
|
-
|
|
486
|
-
SUBTITLES:
|
|
487
|
-
subs <file> [stream_idx] [out] Extract subtitle track (default: first)
|
|
488
|
-
subs_all <file> Extract all subtitle tracks
|
|
489
|
-
|
|
490
|
-
IMAGES:
|
|
491
|
-
thumbnail <file> [time] [out] Extract single frame
|
|
492
|
-
thumbnails <file> [interval] [pattern] Extract frames every N seconds
|
|
493
|
-
|
|
494
|
-
GIF:
|
|
495
|
-
gif <file> [start] [duration] [out] Create GIF (standard quality)
|
|
496
|
-
gif_hq <file> [start] [duration] [out] Create high-quality GIF
|
|
497
|
-
|
|
498
|
-
TRANSFORMATION:
|
|
499
|
-
speed <file> <factor> [out] Change speed (2.0 = 2x faster)
|
|
500
|
-
rotate <file> [dir] [out] Rotate (cw, ccw, 180)
|
|
501
|
-
flip_h <file> [out] Flip horizontally
|
|
502
|
-
flip_v <file> [out] Flip vertically
|
|
503
|
-
crop <file> <w:h:x:y> [out] Crop video
|
|
504
|
-
fps <file> <rate> [out] Change frame rate
|
|
505
|
-
|
|
506
|
-
COMPRESSION:
|
|
507
|
-
compress <file> [crf] [out] Compress (crf: 18-28, higher = smaller)
|
|
508
|
-
compress_small <file> [out] Aggressive compression + 480p
|
|
509
|
-
|
|
510
|
-
OTHER:
|
|
511
|
-
concat <file1> <file2> ... <out> Join multiple videos
|
|
512
|
-
strip_metadata <file> [out] Remove all metadata
|
|
513
|
-
|
|
514
|
-
Time format: HH:MM:SS or seconds (e.g., 01:30:00 or 5400)
|
|
515
|
-
HELP
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
if o.runnable?
|
|
519
|
-
o.run
|
|
520
|
-
else
|
|
521
|
-
puts :no_runnable_found
|
|
522
|
-
end
|
|
523
|
-
|