photein 0.0.7 → 0.1.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/bin/photein +6 -31
- data/lib/photein/config.rb +25 -11
- data/lib/photein/image.rb +9 -11
- data/lib/photein/logger.rb +8 -0
- data/lib/photein/media_file.rb +53 -45
- data/lib/photein/version.rb +1 -1
- data/lib/photein/video.rb +7 -6
- data/lib/photein.rb +12 -0
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 0cc58c5e8f3fad9d829ef5963b0503070ffb4d91e7a766e34acbac7149ad5aeb
|
4
|
+
data.tar.gz: f2d8a39d9c1c4c0e63354ad273eed73ed4aa2c34b42f2a31699bbf17c90f9282
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: db242817983284a1dbc2a3f16dc8929197c2e51f4abee657d8bfa179ac555e7a6c8454fb44bb3622ffd97b22c366bee22ed5b688d4c95d2432369b4df7306c2e
|
7
|
+
data.tar.gz: 0d6a7faef4624037b2f71eee2798d72397cde98f5940727f9ef5878c5a8282b8dc6c836ec70cea59724821d0fe31f9192b3fc590357f9dfe77e942edadea10a5
|
data/bin/photein
CHANGED
@@ -5,26 +5,22 @@ require 'photein'
|
|
5
5
|
require 'pathname'
|
6
6
|
|
7
7
|
Photein::Config.parse_opts!
|
8
|
-
Photein
|
8
|
+
Photein.logger.open
|
9
9
|
|
10
10
|
# Setup ------------------------------------------------------------------------
|
11
11
|
|
12
|
-
SRC_DIR = Pathname(Photein::Config.source)
|
13
|
-
DEST_DIR = Pathname(Photein::Config.dest)
|
14
|
-
|
15
12
|
begin
|
16
|
-
raise "#{Photein::Config.source}: no such directory" unless
|
17
|
-
raise "#{Photein::Config.
|
18
|
-
raise "#{Photein::Config.source}: no photos or videos found" if Dir.empty?(SRC_DIR)
|
13
|
+
raise "#{Photein::Config.source}: no such directory" unless Photein::Config.source.exist?
|
14
|
+
raise "#{Photein::Config.source}: no photos or videos found" if Dir.empty?(Photein::Config.source)
|
19
15
|
rescue => e
|
20
|
-
Photein
|
16
|
+
Photein.logger.fatal(e.message)
|
21
17
|
exit 1
|
22
18
|
end
|
23
19
|
|
24
20
|
# Cleanup ----------------------------------------------------------------------
|
25
21
|
at_exit do
|
26
22
|
unless Photein::Config.keep
|
27
|
-
Dir[
|
23
|
+
Dir[Photein::Config.source.join('**/')].sort
|
28
24
|
.drop(1)
|
29
25
|
.reverse_each { |d| Dir.rmdir(d) if Dir.empty?(d) }
|
30
26
|
end
|
@@ -33,25 +29,4 @@ at_exit do
|
|
33
29
|
end
|
34
30
|
|
35
31
|
# Core Logic -------------------------------------------------------------------
|
36
|
-
|
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)
|
32
|
+
Photein.run
|
data/lib/photein/config.rb
CHANGED
@@ -8,15 +8,16 @@ module Photein
|
|
8
8
|
include Singleton
|
9
9
|
|
10
10
|
OPTIONS = [
|
11
|
-
['-v', '--verbose',
|
12
|
-
['-s SOURCE', '--source=SOURCE',
|
13
|
-
['-
|
14
|
-
['-
|
15
|
-
['-
|
16
|
-
['-
|
17
|
-
['-
|
18
|
-
[
|
19
|
-
[
|
11
|
+
['-v', '--verbose', 'print verbose output'],
|
12
|
+
['-s SOURCE', '--source=SOURCE', 'path to the source directory'],
|
13
|
+
['-m MASTER', '--library-master=MASTER', 'path to a destination directory (master)'],
|
14
|
+
['-d DESKTOP', '--library-desktop=DESKTOP', 'path to a destination directory (desktop-optimized)'],
|
15
|
+
['-w WEB', '--library-web=WEB', 'path to a destination directory (web-optimized)'],
|
16
|
+
['-r', '--recursive', 'ingest source files recursively'],
|
17
|
+
['-k', '--keep', 'do not delete source files'],
|
18
|
+
['-i', '--interactive', 'ask whether to import each file found'],
|
19
|
+
['-n', '--dry-run', 'perform a "no-op" trial run'],
|
20
|
+
[ '--safe', 'skip files in use by other processes'],
|
20
21
|
].freeze
|
21
22
|
|
22
23
|
OPTION_NAMES = OPTIONS
|
@@ -29,7 +30,7 @@ module Photein
|
|
29
30
|
|
30
31
|
class << self
|
31
32
|
def set(**params)
|
32
|
-
@params.replace(params)
|
33
|
+
@params.replace(params)
|
33
34
|
end
|
34
35
|
|
35
36
|
def parse_opts!
|
@@ -46,7 +47,8 @@ module Photein
|
|
46
47
|
@params.freeze
|
47
48
|
|
48
49
|
raise "no source directory given" if !@params.key?(:source)
|
49
|
-
|
50
|
+
(%i[library-master library-desktop library-web] & @params.keys)
|
51
|
+
.then { |dest_dirs| raise "no destination directory given" if dest_dirs.empty? }
|
50
52
|
rescue => e
|
51
53
|
warn("#{parser.program_name}: #{e.message}")
|
52
54
|
warn(parser.help) if e.is_a?(OptionParser::ParseError)
|
@@ -65,6 +67,18 @@ module Photein
|
|
65
67
|
def respond_to_missing?(m, *args)
|
66
68
|
@params.key?(m.to_s.tr('_', '-').to_sym) || super
|
67
69
|
end
|
70
|
+
|
71
|
+
def source
|
72
|
+
@source ||= Pathname(@params[:source])
|
73
|
+
end
|
74
|
+
|
75
|
+
def destinations
|
76
|
+
@destinations ||= {
|
77
|
+
master: Pathname(@params[:'library-master']),
|
78
|
+
desktop: Pathname(@params[:'library-desktop']),
|
79
|
+
web: Pathname(@params[:'library-web'])
|
80
|
+
}.compact
|
81
|
+
end
|
68
82
|
end
|
69
83
|
end
|
70
84
|
end
|
data/lib/photein/image.rb
CHANGED
@@ -22,14 +22,14 @@ module Photein
|
|
22
22
|
}.freeze
|
23
23
|
MAX_RES_WEB = 2097152 # 2MP
|
24
24
|
|
25
|
-
def optimize
|
26
|
-
return
|
25
|
+
def optimize(tempfile:, lib_type:)
|
26
|
+
return unless lib_type == :web
|
27
27
|
|
28
28
|
case extname
|
29
29
|
when '.jpg', '.heic'
|
30
30
|
return false if image.dimensions.reduce(&:*) < MAX_RES_WEB
|
31
31
|
|
32
|
-
Photein
|
32
|
+
Photein.logger.info "optimizing #{path}"
|
33
33
|
MiniMagick::Tool::Convert.new do |convert|
|
34
34
|
convert << path
|
35
35
|
convert.colorspace('sRGB')
|
@@ -42,11 +42,11 @@ module Photein
|
|
42
42
|
end unless Photein::Config.dry_run
|
43
43
|
when '.png'
|
44
44
|
FileUtils.cp(path, tempfile, noop: Photein::Config.dry_run)
|
45
|
-
Photein
|
45
|
+
Photein.logger.info "optimizing #{path}"
|
46
46
|
begin
|
47
47
|
Optipng.optimize(tempfile, level: 4) unless Photein::Config.dry_run
|
48
48
|
rescue Errno::ENOENT
|
49
|
-
Photein
|
49
|
+
Photein.logger.error('optipng is required to compress PNG images')
|
50
50
|
raise
|
51
51
|
end
|
52
52
|
end
|
@@ -57,7 +57,7 @@ module Photein
|
|
57
57
|
def image
|
58
58
|
@image ||= MiniMagick::Image.open(path)
|
59
59
|
rescue MiniMagick::Invalid => e
|
60
|
-
Photein
|
60
|
+
Photein.logger.error(<<~MSG) if e.message.match?(/You must have ImageMagick/)
|
61
61
|
ImageMagick is required to manipulate image files
|
62
62
|
MSG
|
63
63
|
raise
|
@@ -66,7 +66,7 @@ module Photein
|
|
66
66
|
def metadata_stamp
|
67
67
|
MiniExiftool.new(path.to_s).date_time_original
|
68
68
|
rescue MiniExiftool::Error => e
|
69
|
-
Photein
|
69
|
+
Photein.logger.error(<<~MSG) if e.message.match?(/exiftool: not found/)
|
70
70
|
exiftool is required to read timestamp metadata
|
71
71
|
MSG
|
72
72
|
raise
|
@@ -95,10 +95,8 @@ module Photein
|
|
95
95
|
end
|
96
96
|
end
|
97
97
|
|
98
|
-
def non_optimizable_format?
|
99
|
-
return
|
100
|
-
return false if Photein::Config.optimize_for == :desktop
|
101
|
-
return true if extname == '.dng'
|
98
|
+
def non_optimizable_format?(lib_type)
|
99
|
+
return true if lib_type == :web && extname == '.dng'
|
102
100
|
|
103
101
|
return false
|
104
102
|
end
|
data/lib/photein/logger.rb
CHANGED
data/lib/photein/media_file.rb
CHANGED
@@ -23,22 +23,38 @@ module Photein
|
|
23
23
|
return if corrupted?
|
24
24
|
return if Photein::Config.interactive && denied_by_user?
|
25
25
|
return if Photein::Config.safe && in_use?
|
26
|
-
return if Photein::Config.optimize_for && non_optimizable_format?
|
27
26
|
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
27
|
+
Photein::Config.destinations.map do |lib_type, lib_path|
|
28
|
+
next if non_optimizable_format?(lib_type)
|
29
|
+
|
30
|
+
Thread.new do
|
31
|
+
dest_basename = timestamp.strftime(DATE_FORMAT)
|
32
|
+
dest_extname = self.class::OPTIMIZATION_FORMAT_MAP.dig(lib_type, extname) || extname
|
33
|
+
dest_path = lib_path
|
34
|
+
.join(timestamp.strftime('%Y'))
|
35
|
+
.join("#{dest_basename}#{dest_extname}")
|
36
|
+
.then(&method(:resolve_name_collision))
|
37
|
+
tempfile = Pathname(Dir.tmpdir)
|
38
|
+
.join('photein').join(lib_type.to_s)
|
39
|
+
.tap(&FileUtils.method(:mkdir_p))
|
40
|
+
.join(dest_path.basename)
|
41
|
+
|
42
|
+
optimize(tempfile: tempfile, lib_type: lib_type)
|
43
|
+
|
44
|
+
Photein.logger.info(<<~MSG.chomp)
|
45
|
+
#{Photein::Config.keep ? 'copying' : 'moving'} #{path.basename} to #{dest_path}
|
46
|
+
MSG
|
47
|
+
|
48
|
+
FileUtils.mkdir_p(dest_path.dirname, noop: Photein::Config.dry_run)
|
49
|
+
|
50
|
+
if File.exist?(tempfile)
|
51
|
+
FileUtils.mv(tempfile, dest_path, noop: Photein::Config.dry_run)
|
52
|
+
else
|
53
|
+
FileUtils.cp(path, dest_path, noop: Photein::Config.dry_run)
|
54
|
+
FileUtils.chmod('-x', dest_path, noop: Photein::Config.dry_run)
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end.each(&:join)
|
42
58
|
|
43
59
|
FileUtils.rm(path, noop: Photein::Config.dry_run || Photein::Config.keep)
|
44
60
|
end
|
@@ -47,7 +63,7 @@ module Photein
|
|
47
63
|
|
48
64
|
def corrupted?(result = false)
|
49
65
|
return result.tap do |r|
|
50
|
-
Photein
|
66
|
+
Photein.logger.error("#{path.basename}: cannot import corrupted file") if r
|
51
67
|
end
|
52
68
|
end
|
53
69
|
|
@@ -61,36 +77,17 @@ module Photein
|
|
61
77
|
|
62
78
|
if status.success? # Do open files ALWAYS return exit status 0? (I think so.)
|
63
79
|
cmd, pid = out.lines[1]&.split&.first(2)
|
64
|
-
Photein
|
80
|
+
Photein.logger.fatal("skipping #{path}: file in use by #{cmd} (PID #{pid})")
|
65
81
|
return true
|
66
82
|
else
|
67
83
|
return false
|
68
84
|
end
|
69
85
|
end
|
70
86
|
|
71
|
-
def non_optimizable_format? # may be overridden by subclasses
|
87
|
+
def non_optimizable_format?(lib_type = :master) # may be overridden by subclasses
|
72
88
|
return false
|
73
89
|
end
|
74
90
|
|
75
|
-
def parent_dir
|
76
|
-
Pathname(Photein::Config.dest).join(timestamp.strftime('%Y'))
|
77
|
-
end
|
78
|
-
|
79
|
-
def tempfile
|
80
|
-
Pathname(Dir.tmpdir).join('photein')
|
81
|
-
.tap(&FileUtils.method(:mkdir_p))
|
82
|
-
.join(dest_path.basename)
|
83
|
-
end
|
84
|
-
|
85
|
-
def dest_path
|
86
|
-
@dest_path ||= begin
|
87
|
-
base_path = parent_dir.join("#{timestamp.strftime(DATE_FORMAT)}#{dest_extname}")
|
88
|
-
counter = resolve_name_collision(base_path.sub_ext("*#{dest_extname}"))
|
89
|
-
|
90
|
-
base_path.sub_ext("#{counter}#{dest_extname}")
|
91
|
-
end
|
92
|
-
end
|
93
|
-
|
94
91
|
def timestamp
|
95
92
|
@timestamp ||= (metadata_stamp || filename_stamp)
|
96
93
|
end
|
@@ -105,29 +102,40 @@ module Photein
|
|
105
102
|
end
|
106
103
|
end
|
107
104
|
|
108
|
-
def dest_extname
|
109
|
-
self.class::OPTIMIZATION_FORMAT_MAP
|
110
|
-
.dig(Photein::Config.optimize_for, extname) || extname
|
111
|
-
end
|
112
|
-
|
113
105
|
def extname
|
114
106
|
@extname ||= NORMAL_EXTNAME_MAP[path.extname.downcase] || path.extname.downcase
|
115
107
|
end
|
116
108
|
|
117
|
-
def resolve_name_collision(
|
109
|
+
def resolve_name_collision(filename)
|
110
|
+
raise ArgumentError, 'Invalid filename' if filename.to_s.include?('*')
|
111
|
+
|
112
|
+
collision_glob = Pathname(filename).sub_ext("*#{filename.extname}")
|
113
|
+
|
118
114
|
case Dir[collision_glob].length
|
119
115
|
when 0 # if no files found, no biggie
|
120
116
|
when 1 # if one file found, WITH OR WITHOUT COUNTER, reset counter to a
|
121
117
|
if Dir[collision_glob].first != collision_glob.sub('*', 'a') # don't try if it's already a lone, correctly-countered file
|
122
|
-
Photein
|
118
|
+
Photein.logger.info('conflicting timestamp found; adding counter to existing file')
|
123
119
|
FileUtils.mv(Dir[collision_glob].first, collision_glob.sub('*', 'a'), noop: Photein::Config.dry_run)
|
124
120
|
end
|
125
121
|
else # TODO: if multiple files found, rectify them?
|
126
122
|
end
|
127
123
|
|
128
|
-
# return the next usable
|
124
|
+
# return the next usable filename
|
129
125
|
Dir[collision_glob].max&.slice(/.(?=#{Regexp.escape(collision_glob.extname)})/)&.next
|
130
126
|
.tap { |counter| raise 'Unresolved timestamp conflict' unless [*Array('a'..'z'), nil].include?(counter) }
|
127
|
+
.then { |counter| filename.sub_ext("#{counter}#{filename.extname}") }
|
128
|
+
end
|
129
|
+
|
130
|
+
class << self
|
131
|
+
def for(file)
|
132
|
+
file = Pathname(file)
|
133
|
+
raise Errno::ENOENT, "#{file}" unless file.exist?
|
134
|
+
|
135
|
+
[Image, Video].find { |type| type::SUPPORTED_FORMATS.include?(file.extname) }
|
136
|
+
.tap { |type| raise ArgumentError, "#{file}: Invalid media file" if type.nil? }
|
137
|
+
.then { |type| type.new(file) }
|
138
|
+
end
|
131
139
|
end
|
132
140
|
end
|
133
141
|
end
|
data/lib/photein/version.rb
CHANGED
data/lib/photein/video.rb
CHANGED
@@ -33,10 +33,11 @@ module Photein
|
|
33
33
|
web: '35',
|
34
34
|
}.freeze
|
35
35
|
|
36
|
-
def optimize
|
37
|
-
return if
|
36
|
+
def optimize(tempfile:, lib_type:)
|
37
|
+
return if lib_type == :master
|
38
|
+
return if video.bitrate < BITRATE_THRESHOLD[lib_type]
|
38
39
|
|
39
|
-
Photein
|
40
|
+
Photein.logger.info("transcoding #{tempfile}")
|
40
41
|
return if Photein::Config.dry_run
|
41
42
|
|
42
43
|
video.transcode(
|
@@ -45,7 +46,7 @@ module Photein
|
|
45
46
|
'-map_metadata', '0', # https://video.stackexchange.com/a/26076
|
46
47
|
'-movflags', 'use_metadata_tags',
|
47
48
|
'-c:v', 'libx264',
|
48
|
-
'-crf', TARGET_CRF[
|
49
|
+
'-crf', TARGET_CRF[lib_type],
|
49
50
|
],
|
50
51
|
&method(:display_progress_bar)
|
51
52
|
)
|
@@ -60,7 +61,7 @@ module Photein
|
|
60
61
|
def video
|
61
62
|
@video ||= FFMPEG::Movie.new(path.to_s)
|
62
63
|
rescue Errno::ENOENT
|
63
|
-
Photein
|
64
|
+
Photein.logger.error('ffmpeg is required to manipulate video files')
|
64
65
|
raise
|
65
66
|
end
|
66
67
|
|
@@ -68,7 +69,7 @@ module Photein
|
|
68
69
|
# video timestamps are typically UTC
|
69
70
|
MediaInfo.from(path.to_s).general.encoded_date&.getlocal
|
70
71
|
rescue MediaInfo::EnvironmentError
|
71
|
-
Photein
|
72
|
+
Photein.logger.error('mediainfo is required to read timestamp metadata')
|
72
73
|
raise
|
73
74
|
end
|
74
75
|
|
data/lib/photein.rb
CHANGED
@@ -8,4 +8,16 @@ require 'photein/image'
|
|
8
8
|
require 'photein/video'
|
9
9
|
|
10
10
|
module Photein
|
11
|
+
class << self
|
12
|
+
def run
|
13
|
+
[Photein::Image, Photein::Video].each do |media_type|
|
14
|
+
Pathname(Photein::Config.source)
|
15
|
+
.join(Photein::Config.recursive ? '**' : '')
|
16
|
+
.join("*{#{media_type::SUPPORTED_FORMATS.join(',')}}")
|
17
|
+
.then { |glob| Dir.glob(glob, File::FNM_CASEFOLD).sort }
|
18
|
+
.map(&media_type.method(:new))
|
19
|
+
.each(&:import)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
11
23
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: photein
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.1.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Ryan Lue
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2021-12-
|
11
|
+
date: 2021-12-10 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: mediainfo
|