hiiro 0.1.0

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