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 +7 -0
- data/README.md +117 -0
- data/bin/photein +57 -0
- data/lib/photein.rb +11 -0
- data/lib/photein/config.rb +66 -0
- data/lib/photein/image.rb +92 -0
- data/lib/photein/logger.rb +30 -0
- data/lib/photein/media_file.rb +126 -0
- data/lib/photein/version.rb +5 -0
- data/lib/photein/video.rb +96 -0
- data/vendor/terminal-size/lib/terminal-size.rb +57 -0
- metadata +166 -0
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,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,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: []
|