photein 0.0.1

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 6e24eda13e60ea0e81a7ac8005f43f9e9a6bf3f04a48893cab7c10f89fd2021a
4
+ data.tar.gz: 7bca13e3736b841bc6ef20f246f3fc6db4c812a31b4204cd0af5399ade995a61
5
+ SHA512:
6
+ metadata.gz: ed7adb5d47bb15496c5c9a30fe9ac54c616b43cc2dace96d611431005496efb5460d374544fa62a95a68abf249150f2983af5338a76080dfebe938bd6448e4e2
7
+ data.tar.gz: c17406ac9266527a2d0d85dc61bbc5eeadc90bae17f8ab94885e0616d7d8e06aa400f71b795d2b5c5504853f11c75e76f0a54b5d8d893fdde3f3469860193d83
data/README.md ADDED
@@ -0,0 +1,117 @@
1
+ Ph📸tein
2
+ ========
3
+
4
+ Import/rename photos & videos from one directory to another.
5
+
6
+ Why?
7
+ ----
8
+
9
+ * The major cloud photo services (iCloud, Google Photos) are great but not FOSS.
10
+
11
+ (My digital photo/video library belongs to me,
12
+ but if I don’t control the pipeline for viewing/managing it,
13
+ then does it really?)
14
+
15
+ * Importing photos from many sources
16
+ (📱 cell phone / 📷 digital camera / 💬 chat app)
17
+ into one library with as few manual steps as possible
18
+ is not simple, especially on Linux.
19
+
20
+ What does it do?
21
+ ----------------
22
+
23
+ Suppose your digital camera creates files with names like `R0017839.JPG`.
24
+ Photein will...
25
+
26
+ * look for a timestamp in...
27
+ * EXIF metadata
28
+ * filename
29
+ * file birthtime
30
+ * rename files by that timestamp (`YYYY-mm-dd_HHMMSS.jpg`)
31
+ * handle conflicts for identical timestamps (`YYYY-mm-dd_HHMMSSa.jpg`, `YYYY-mm-dd_HHMMSSb.jpg`...)
32
+ * sort files into subdirectories by year (`YYYY/YYYY-mm-dd_HHMMSS.jpg`)
33
+ * optionally, optimize media for reduced filesize
34
+
35
+ #### Supported media formats
36
+
37
+ * .jpg
38
+ * .dng
39
+ * .heic
40
+ * .png
41
+ * .mp4
42
+ * .mov
43
+
44
+ ### So how do I use it?
45
+
46
+ #### 📷 Auto-import from a digital camera (Linux)
47
+
48
+ Customize the provided [sample systemd service][]
49
+ to mount, import from, and unmount your camera
50
+ whenever you plug it in via USB.
51
+
52
+ ```sh
53
+ $ mkdir -p ~/.local/share/systemd/user
54
+ $ curl https://raw.githubusercontent.com/rlue/photein/master/examples/share/systemd/user/photein-dcim.service -o ~/.local/share/systemd/user/photein-dcim.service
55
+ $ systemctl --user daemon-reload
56
+ $ systemctl --user enable photein-dcim.service
57
+ ```
58
+
59
+ > Note: The provided systemd service makes the following
60
+ > assumptions:
61
+ >
62
+ > * Your device’s label is `RICOH_GR`.
63
+ > (Use `systemctl --all --full -t device`
64
+ > to determine the label of your USB device.)
65
+ > * You use [rbenv][] to manage your system’s Ruby environment.
66
+ >
67
+ > Adjust accordingly.
68
+
69
+ [sample systemd service]: blob/master/examples/share/systemd/user/photein-dcim.service
70
+ [rbenv]: https://github.com/rbenv/rbenv
71
+
72
+ #### 📱 Auto-import from an Android phone
73
+
74
+ Use [Syncthing][] to sync photos from your phone to a staging directory on
75
+ your computer. Then, run photein in a cron job to import those photos into
76
+ your library on a daily basis.
77
+
78
+ [Syncthing]: https://syncthing.net/
79
+
80
+ Installation
81
+ ------------
82
+
83
+ ```sh
84
+ $ git clone https://github.com/rlue/photein
85
+ $ cd photein
86
+ $ gem build photein.gemspec
87
+ $ gem install photein-0.0.1.gem
88
+ ```
89
+
90
+ Usage
91
+ -----
92
+
93
+ ```sh
94
+ $ photein \
95
+ --source=/media/ricoh_gr/DCIM \ # pull photos & videos from here
96
+ --dest=/home/rlue/Pictures # and deposit them here (in per-year subdirectories)
97
+ ```
98
+
99
+ Use `photein --help` for a summary of all options.
100
+
101
+ Dependencies
102
+ ------------
103
+
104
+ * [ExifTool][]
105
+ * [MediaInfo][]
106
+ * ImageMagick (for `--optimize-for=web` option)
107
+ * OptiPNG (for `--optimize-for=web` option)
108
+ * ffmpeg (for `--optimize-for={web,desktop}` options)
109
+ * lsof (for `--safe` option)
110
+
111
+ [ExifTool]: https://exiftool.org/
112
+ [MediaInfo]: https://mediaarea.net/MediaInfo
113
+
114
+ License
115
+ -------
116
+
117
+ © 2021 Ryan Lue. This project is licensed under the terms of the MIT License.
data/bin/photein ADDED
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'photein'
5
+ require 'pathname'
6
+
7
+ Photein::Config.parse_opts!
8
+ Photein::Logger.open
9
+
10
+ # Setup ------------------------------------------------------------------------
11
+
12
+ SRC_DIR = Pathname(Photein::Config.source)
13
+ DEST_DIR = Pathname(Photein::Config.dest)
14
+
15
+ begin
16
+ raise "#{Photein::Config.source}: no such directory" unless SRC_DIR.exist?
17
+ raise "#{Photein::Config.dest}: no such directory" unless DEST_DIR.exist?
18
+ raise "#{Photein::Config.source}: no photos or videos found" if Dir.empty?(SRC_DIR)
19
+ rescue => e
20
+ Photein::Logger.fatal(e.message)
21
+ exit 1
22
+ end
23
+
24
+ # Cleanup ----------------------------------------------------------------------
25
+ at_exit do
26
+ unless Photein::Config.keep
27
+ Dir[SRC_DIR.join('**/')].sort
28
+ .drop(1)
29
+ .reverse_each { |d| Dir.rmdir(d) if Dir.empty?(d) }
30
+ end
31
+
32
+ FileUtils.rm_rf(File.join(Dir.tmpdir, 'photein'))
33
+ end
34
+
35
+ # Core Logic -------------------------------------------------------------------
36
+ image_formats = Photein::Image::SUPPORTED_FORMATS
37
+ .zip(Photein::Image::SUPPORTED_FORMATS.map(&:upcase))
38
+ .flatten
39
+
40
+ SRC_DIR
41
+ .join(Photein::Config.recursive ? '**' : '')
42
+ .join("*{#{image_formats.join(',')}}")
43
+ .then { |glob| Dir[glob].sort }
44
+ .map(&Photein::Image.method(:new))
45
+ .each(&:import)
46
+
47
+ # Video compression is time-consuming, so save it for last
48
+ video_formats = Photein::Video::SUPPORTED_FORMATS
49
+ .zip(Photein::Video::SUPPORTED_FORMATS.map(&:upcase))
50
+ .flatten
51
+
52
+ SRC_DIR
53
+ .join(Photein::Config.recursive ? '**' : '')
54
+ .join("*{#{video_formats.join(',')}}")
55
+ .then { |glob| Dir[glob].sort }
56
+ .map(&Photein::Video.method(:new))
57
+ .each(&:import)
data/lib/photein.rb ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'photein/version'
4
+ require 'photein/config'
5
+ require 'photein/logger'
6
+ require 'photein/media_file'
7
+ require 'photein/image'
8
+ require 'photein/video'
9
+
10
+ module Photein
11
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'singleton'
4
+ require 'optparse'
5
+
6
+ module Photein
7
+ class Config
8
+ include Singleton
9
+
10
+ OPTIONS = [
11
+ ['-v', '--verbose', 'print verbose output'],
12
+ ['-s SOURCE', '--source=SOURCE', 'specify the source directory'],
13
+ ['-d DESTINATION', '--dest=DESTINATION', 'specify the destination directory'],
14
+ ['-r', '--recursive', 'ingest source files recursively'],
15
+ ['-k', '--keep', 'do not delete source files'],
16
+ ['-i', '--interactive', 'ask whether to import each file found'],
17
+ ['-n', '--dry-run', 'perform a "no-op" trial run'],
18
+ [ '--safe', 'skip files in use by other processes'],
19
+ [ '--optimize-for=TARGET', %i[desktop web], 'compress images/video before importing']
20
+ ].freeze
21
+
22
+ OPTION_NAMES = OPTIONS
23
+ .flatten
24
+ .grep(/^--/)
25
+ .map { |option| option[/\w[a-z\-]+/] }
26
+ .map(&:to_sym)
27
+
28
+ class << self
29
+ def parse_opts!
30
+ @params = {}
31
+
32
+ parser = OptionParser.new do |opts|
33
+ opts.version = Photein::VERSION
34
+ opts.banner = <<~BANNER
35
+ Usage: photein [--version] [-h | --help] [<args>]
36
+ BANNER
37
+
38
+ OPTIONS.each { |opt| opts.on(*opt) }
39
+ end.tap { |p| p.parse!(into: @params) }
40
+
41
+ @params[:verbose] ||= @params[:'dry-run']
42
+ @params.freeze
43
+
44
+ raise "no source directory given" if !@params.key?(:source)
45
+ raise "no destination directory given" if !@params.key?(:dest)
46
+ rescue => e
47
+ warn("#{parser.program_name}: #{e.message}")
48
+ warn(parser.help) if e.is_a?(OptionParser::ParseError)
49
+ exit 1
50
+ end
51
+
52
+ def [](key)
53
+ @params[key]
54
+ end
55
+
56
+ def method_missing(m, *args, &blk)
57
+ m.to_s.tr('_', '-').to_sym
58
+ .then { |key| OPTION_NAMES.include?(key) ? self[key] : super }
59
+ end
60
+
61
+ def respond_to_missing?(m, *args)
62
+ @params.key?(m.to_s.tr('_', '-').to_sym) || super
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'time'
5
+
6
+ require 'photein/media_file'
7
+ require 'mini_exiftool'
8
+ require 'mini_magick'
9
+ require 'optipng'
10
+
11
+ module Photein
12
+ class Image < MediaFile
13
+ SUPPORTED_FORMATS = %w(
14
+ .jpg
15
+ .jpeg
16
+ .dng
17
+ .heic
18
+ .png
19
+ ).freeze
20
+ OPTIMIZATION_FORMAT_MAP = {
21
+ web: { '.heic' => '.jpg' }
22
+ }.freeze
23
+ MAX_RES_WEB = 2097152 # 2MP
24
+
25
+ def optimize
26
+ return if Photein::Config.optimize_for == :desktop
27
+
28
+ case extname
29
+ when '.jpg', '.heic'
30
+ return false if image.dimensions.reduce(&:*) < MAX_RES_WEB
31
+
32
+ Photein::Logger.info "optimizing #{path}"
33
+ MiniMagick::Tool::Convert.new do |convert|
34
+ convert << path
35
+ convert.colorspace('sRGB')
36
+ convert.define('jpeg:dct-method=float')
37
+ convert.interlace('JPEG')
38
+ convert.quality('85%')
39
+ convert.resize("#{MAX_RES_WEB}@>")
40
+ convert.sampling_factor('4:2:0')
41
+ convert << tempfile
42
+ end unless Photein::Config.dry_run
43
+ when '.png'
44
+ return if !Optipng.available?
45
+
46
+ FileUtils.cp(path, tempfile, noop: Photein::Config.dry_run)
47
+ Optipng.optimize(tempfile, level: 4) unless Photein::Config.dry_run
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def image
54
+ @image ||= MiniMagick::Image.open(path)
55
+ end
56
+
57
+ def metadata_stamp
58
+ MiniExiftool.new(path.to_s).date_time_original
59
+ end
60
+
61
+ # NOTE: This may be largely unnecessary:
62
+ # metadata timestamps are generally present in all cases except WhatsApp
63
+ def filename_stamp
64
+ path.basename(path.extname).to_s.then do |filename|
65
+ case filename
66
+ when /^IMG_\d{8}_\d{6}(_\d{3})?$/ # Android DCIM: datetime + optional counter
67
+ Time.strptime(filename[0, 19], 'IMG_%Y%m%d_%H%M%S')
68
+ when /^\d{13}$/ # LINE: UNIX time in milliseconds (at download)
69
+ Time.strptime(filename[0..-4], '%s')
70
+ when /^IMG-\d{8}-WA\d{4}$/ # WhatsApp: date + counter (at receipt)
71
+ Time.strptime(filename, 'IMG-%Y%m%d-WA%M%S')
72
+ when /^IMG_\d{8}_\d{6}_\d{3}$/ # Telegram: datetime in milliseconds (at download)
73
+ Time.strptime(filename, 'IMG_%Y%m%d_%H%M%S_%L')
74
+ when /^signal-\d{4}-\d{2}-\d{2}-\d{6}( \(\d+\))?$/ # Signal: datetime + optional counter (at receipt)
75
+ Time.strptime(filename[0, 24], 'signal-%F-%H%M%S')
76
+ when /^\d{13}$/ # LINE: UNIX time in milliseconds (at download)
77
+ Time.strptime(filename[0..-4], '%s')
78
+ else
79
+ super
80
+ end
81
+ end
82
+ end
83
+
84
+ def non_optimizable_format?
85
+ return false if !Photein::Config.optimize_for
86
+ return false if Photein::Config.optimize_for == :desktop
87
+ return true if extname == '.dng'
88
+
89
+ return false
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+ require 'singleton'
5
+
6
+ module Photein
7
+ class Logger
8
+ include Singleton
9
+
10
+ class << self
11
+ attr_reader :stdout, :stderr
12
+
13
+ def open
14
+ @stdout = ::Logger.new($stdout)
15
+ @stderr = ::Logger.new($stderr)
16
+
17
+ Photein::Config.verbose ? stdout.debug! : stdout.info!
18
+ Photein::Config.verbose ? stderr.warn! : stderr.fatal!
19
+ end
20
+
21
+ %i[unknown fatal error warn].each do |m|
22
+ define_method(m) { |*args| stderr.send(m, *args) }
23
+ end
24
+
25
+ %i[info debug].each do |m|
26
+ define_method(m) { |*args| stdout.send(m, *args) }
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'io/console'
5
+ require 'open3'
6
+ require 'time'
7
+
8
+ module Photein
9
+ class MediaFile
10
+ DATE_FORMAT = '%F_%H%M%S'.freeze
11
+
12
+ NORMAL_EXTNAME_MAP = {
13
+ '.jpeg' => '.jpg'
14
+ }.freeze
15
+
16
+ attr_reader :path
17
+
18
+ def initialize(path)
19
+ @path = Pathname(path)
20
+ end
21
+
22
+ def import
23
+ return if Photein::Config.interactive && denied_by_user?
24
+ return if Photein::Config.safe && in_use?
25
+ return if Photein::Config.optimize_for && non_optimizable_format?
26
+
27
+ FileUtils.mkdir_p(parent_dir, noop: Photein::Config.dry_run)
28
+
29
+ optimize if Photein::Config.optimize_for
30
+
31
+ Photein::Logger.info(<<~MSG.chomp)
32
+ #{Photein::Config.keep ? 'copying' : 'moving'} #{path.basename} to #{dest_path}
33
+ MSG
34
+
35
+ if File.exist?(tempfile)
36
+ FileUtils.mv(tempfile, dest_path, noop: Photein::Config.dry_run)
37
+ else
38
+ FileUtils.cp(path, dest_path, noop: Photein::Config.dry_run)
39
+ FileUtils.chmod('-x', dest_path, noop: Photein::Config.dry_run)
40
+ end
41
+
42
+ FileUtils.rm(path, noop: Photein::Config.dry_run || Photein::Config.keep)
43
+ end
44
+
45
+ private
46
+
47
+ def denied_by_user?
48
+ $stdout.printf "Import #{path}? [y/N]"
49
+ (STDIN.getch.downcase != 'y').tap { $stdout.puts }
50
+ end
51
+
52
+ def in_use?
53
+ out, _err, status = Open3.capture3("lsof '#{path}'")
54
+
55
+ if status.success? # Do open files ALWAYS return exit status 0? (I think so.)
56
+ cmd, pid = out.lines[1]&.split&.first(2)
57
+ Photein::Logger.fatal("skipping #{path}: file in use by #{cmd} (PID #{pid})")
58
+ return true
59
+ else
60
+ return false
61
+ end
62
+ end
63
+
64
+ def non_optimizable_format? # may be overridden by subclasses
65
+ return false
66
+ end
67
+
68
+ def parent_dir
69
+ Pathname(Photein::Config.dest).join(timestamp.strftime('%Y'))
70
+ end
71
+
72
+ def tempfile
73
+ Pathname(Dir.tmpdir).join('photein')
74
+ .tap(&FileUtils.method(:mkdir_p))
75
+ .join(dest_path.basename)
76
+ end
77
+
78
+ def dest_path
79
+ @dest_path ||= begin
80
+ base_path = parent_dir.join("#{timestamp.strftime(DATE_FORMAT)}#{dest_extname}")
81
+ counter = resolve_name_collision(base_path.sub_ext("*#{dest_extname}"))
82
+
83
+ base_path.sub_ext("#{counter}#{dest_extname}")
84
+ end
85
+ end
86
+
87
+ def timestamp
88
+ @timestamp ||= (metadata_stamp || filename_stamp)
89
+ end
90
+
91
+ def filename_stamp
92
+ Time.parse(path.basename(path.extname).to_s)
93
+ rescue ArgumentError
94
+ begin
95
+ File.birthtime(path)
96
+ rescue NotImplementedError
97
+ File.mtime(path)
98
+ end
99
+ end
100
+
101
+ def dest_extname
102
+ self.class::OPTIMIZATION_FORMAT_MAP
103
+ .dig(Photein::Config.optimize_for, extname) || extname
104
+ end
105
+
106
+ def extname
107
+ @extname ||= NORMAL_EXTNAME_MAP[path.extname.downcase] || path.extname.downcase
108
+ end
109
+
110
+ def resolve_name_collision(collision_glob)
111
+ case Dir[collision_glob].length
112
+ when 0 # if no files found, no biggie
113
+ when 1 # if one file found, WITH OR WITHOUT COUNTER, reset counter to a
114
+ if Dir[collision_glob].first != collision_glob.sub('*', 'a') # don't try if it's already a lone, correctly-countered file
115
+ Photein::Logger.info('conflicting timestamp found; adding counter to existing file')
116
+ FileUtils.mv(Dir[collision_glob].first, collision_glob.sub('*', 'a'))
117
+ end
118
+ else # TODO: if multiple files found, rectify them?
119
+ end
120
+
121
+ # return the next usable counter
122
+ Dir[collision_glob].max&.slice(/.(?=#{Regexp.escape(collision_glob.extname)})/)&.next
123
+ .tap { |counter| raise 'Unresolved timestamp conflict' unless [*Array('a'..'z'), nil].include?(counter) }
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Photein
4
+ VERSION = '0.0.1'
5
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'time'
5
+
6
+ require 'photein/media_file'
7
+ require 'mediainfo'
8
+ require 'streamio-ffmpeg'
9
+ require_relative '../../vendor/terminal-size/lib/terminal-size'
10
+
11
+ module Photein
12
+ class Video < MediaFile
13
+ FFMPEG.logger.warn!
14
+
15
+ SUPPORTED_FORMATS = %w(
16
+ .mov
17
+ .mp4
18
+ .webm
19
+ ).freeze
20
+
21
+ OPTIMIZATION_FORMAT_MAP = {
22
+ desktop: { '.mov' => '.mp4' },
23
+ web: { '.mov' => '.mp4' }
24
+ }.freeze
25
+
26
+ BITRATE_THRESHOLD = {
27
+ desktop: 8388608, # 1MB/s
28
+ web: 2097152, # 0.25MB/s
29
+ }.freeze
30
+
31
+ TARGET_CRF = {
32
+ desktop: '28',
33
+ web: '35',
34
+ }.freeze
35
+
36
+ def optimize
37
+ return if video.bitrate < BITRATE_THRESHOLD[Photein::Config.optimize_for]
38
+
39
+ Photein::Logger.info("transcoding #{tempfile}")
40
+ return if Photein::Config.dry_run
41
+
42
+ video.transcode(
43
+ tempfile.to_s,
44
+ [
45
+ '-map_metadata', '0', # https://video.stackexchange.com/a/26076
46
+ '-movflags', 'use_metadata_tags',
47
+ '-c:v', 'libx264',
48
+ '-crf', TARGET_CRF[Photein::Config.optimize_for],
49
+ ],
50
+ &method(:display_progress_bar)
51
+ )
52
+ end
53
+
54
+ private
55
+
56
+ def video
57
+ @video ||= FFMPEG::Movie.new(path.to_s)
58
+ end
59
+
60
+ def metadata_stamp
61
+ # video timestamps are typically UTC
62
+ MediaInfo.from(path.to_s).general.encoded_date&.getlocal
63
+ end
64
+
65
+ # NOTE: This may be largely unnecessary:
66
+ # metadata timestamps are generally present in all cases except WhatsApp
67
+ def filename_stamp
68
+ path.basename(path.extname).to_s.then do |filename|
69
+ case filename
70
+ when /^LINE_MOVIE_\d{13}$/ # LINE: UNIX time in milliseconds (at download)
71
+ Time.strptime(filename[0..-4], 'LINE_MOVIE_%s')
72
+ when /^VID-\d{8}-WA\d{4}$/ # WhatsApp: date + counter (at receipt)
73
+ Time.strptime(filename, 'VID-%Y%m%d-WA%M%S')
74
+ when /^VID_\d{8}_\d{6}_\d{3}$/ # Telegram: datetime in milliseconds (at download)
75
+ Time.strptime(filename, 'VID_%Y%m%d_%H%M%S_%L')
76
+ when /^signal-\d{4}-\d{2}-\d{2}-\d{6}( \(\d+\))?$/ # Signal: datetime + optional counter (at receipt)
77
+ Time.strptime(filename[0, 24], 'signal-%F-%H%M%S')
78
+ else
79
+ super
80
+ end
81
+ end
82
+ end
83
+
84
+ def display_progress_bar(progress)
85
+ return unless $stdout.tty?
86
+
87
+ percentage = "#{(progress * 100).to_i.to_s}%".rjust(5)
88
+ window_width = Terminal.size[:width]
89
+ bar_len = window_width - 7
90
+ progress_len = (bar_len * progress).to_i
91
+ bg_len = bar_len - progress_len
92
+ progress_bar = "[#{'#' * progress_len}#{'-' * bg_len}]#{percentage}"
93
+ print "#{progress_bar}\r"
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,57 @@
1
+ # `terminal-size` gem, Copyleft (ↄ) 2012 ☈king — Absolutely CC0 / Public Domain / Whateverness.
2
+
3
+ class Terminal
4
+ class Size; VERSION = '0.0.6' end
5
+ class << self
6
+ def size
7
+ size_via_low_level_ioctl or size_via_stty or nil
8
+ end
9
+ def size!; size or _height_width_hash_from 25, 80 end
10
+
11
+ # These are experimental
12
+ def resize direction, magnitude
13
+ tmux 'resize-pane', "-#{direction}", magnitude
14
+ end
15
+
16
+ def tmux *cmd
17
+ system 'tmux', *(cmd.map &:to_s)
18
+ end
19
+
20
+ IOCTL_INPUT_BUF = "\x00"*8
21
+ def size_via_low_level_ioctl
22
+ # Thanks to runpaint for the general approach to this
23
+ return unless $stdin.respond_to? :ioctl
24
+ code = tiocgwinsz_value_for RUBY_PLATFORM
25
+ return unless code
26
+ buf = IOCTL_INPUT_BUF.dup
27
+ return unless $stdout.ioctl(code, buf).zero?
28
+ return if IOCTL_INPUT_BUF == buf
29
+ got = buf.unpack('S4')[0..1]
30
+ _height_width_hash_from *got
31
+ rescue
32
+ nil
33
+ end
34
+
35
+ def tiocgwinsz_value_for platform
36
+ # This is as reported by <sys/ioctl.h>
37
+ # Hard-coding because it seems like overkll to acutally involve C for this.
38
+ {
39
+ /linux/ => 0x5413,
40
+ /darwin/ => 0x40087468, # thanks to brandon@brandon.io for the lookup!
41
+ }.find{|k,v| platform[k]}
42
+ end
43
+
44
+ def size_via_stty
45
+ ints = `stty size`.scan(/\d+/).map &:to_i
46
+ _height_width_hash_from *ints
47
+ rescue
48
+ nil
49
+ end
50
+
51
+ private
52
+ def _height_width_hash_from *dimensions
53
+ { :height => dimensions[0], :width => dimensions[1] }
54
+ end
55
+
56
+ end
57
+ end
metadata ADDED
@@ -0,0 +1,166 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: photein
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Ryan Lue
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2021-05-20 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: mediainfo
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.5'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.5'
27
+ - !ruby/object:Gem::Dependency
28
+ name: mini_exiftool
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '2.10'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '2.10'
41
+ - !ruby/object:Gem::Dependency
42
+ name: mini_magick
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '4.11'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '4.11'
55
+ - !ruby/object:Gem::Dependency
56
+ name: optipng
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '0.2'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '0.2'
69
+ - !ruby/object:Gem::Dependency
70
+ name: nokogiri
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '1.11'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '1.11'
83
+ - !ruby/object:Gem::Dependency
84
+ name: streamio-ffmpeg
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '3.0'
90
+ type: :runtime
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '3.0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: pry
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '0.14'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '0.14'
111
+ - !ruby/object:Gem::Dependency
112
+ name: rspec
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: '3.10'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: '3.10'
125
+ description: ''
126
+ email: hello@ryanlue.com
127
+ executables:
128
+ - photein
129
+ extensions: []
130
+ extra_rdoc_files: []
131
+ files:
132
+ - README.md
133
+ - bin/photein
134
+ - lib/photein.rb
135
+ - lib/photein/config.rb
136
+ - lib/photein/image.rb
137
+ - lib/photein/logger.rb
138
+ - lib/photein/media_file.rb
139
+ - lib/photein/version.rb
140
+ - lib/photein/video.rb
141
+ - vendor/terminal-size/lib/terminal-size.rb
142
+ homepage: https://github.com/rlue/photein
143
+ licenses:
144
+ - MIT
145
+ metadata:
146
+ source_code_uri: https://github.com/rlue/photein
147
+ post_install_message:
148
+ rdoc_options: []
149
+ require_paths:
150
+ - lib
151
+ required_ruby_version: !ruby/object:Gem::Requirement
152
+ requirements:
153
+ - - ">="
154
+ - !ruby/object:Gem::Version
155
+ version: 2.6.0
156
+ required_rubygems_version: !ruby/object:Gem::Requirement
157
+ requirements:
158
+ - - ">="
159
+ - !ruby/object:Gem::Version
160
+ version: '0'
161
+ requirements: []
162
+ rubygems_version: 3.2.3
163
+ signing_key:
164
+ specification_version: 4
165
+ summary: Import/rename photos & videos from one directory to another.
166
+ test_files: []