photein 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- 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: []
|