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.
- checksums.yaml +7 -0
- data/.config/.keep +0 -0
- data/CLAUDE.md +101 -0
- data/LICENSE +21 -0
- data/README.md +276 -0
- data/Rakefile +3 -0
- data/bin/h +415 -0
- data/bin/h-buffer +46 -0
- data/bin/h-pane +63 -0
- data/bin/h-plugin +51 -0
- data/bin/h-session +43 -0
- data/bin/h-video +522 -0
- data/bin/h-window +59 -0
- data/docs/README.md +41 -0
- data/docs/h-buffer.md +52 -0
- data/docs/h-pane.md +71 -0
- data/docs/h-plugin.md +58 -0
- data/docs/h-session.md +63 -0
- data/docs/h-video.md +172 -0
- data/docs/h-window.md +77 -0
- data/exe/hiiro +13 -0
- data/hiiro.gemspec +30 -0
- data/lib/hiiro/version.rb +3 -0
- data/lib/hiiro.rb +389 -0
- data/plugins/notify.rb +30 -0
- data/plugins/pins.rb +113 -0
- data/plugins/project.rb +75 -0
- data/plugins/task.rb +679 -0
- data/plugins/tmux.rb +29 -0
- data/script/compare +14 -0
- data/script/install +16 -0
- metadata +92 -0
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
|