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.
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
-