photein 0.0.8 → 0.1.2
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 +4 -4
- data/bin/photein +5 -9
- data/lib/photein/config.rb +24 -10
- 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
- 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: 808d8afeddb75b1ec710f2335b5e1f51d9d6ffec91b1cb5a2cd2c1d8950486ca
|
4
|
+
data.tar.gz: b45b43586500db69c52fcc2a046063b90e1e375dd4d5ef41a042eef0a2a5a984
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: d9c2de4bf1b3e0433dbee191e44ef80c2d910a1416c02ccc0bd44fda3acc583282b2a07f2745a1c2345d39a83d055c270604858163b83c39ba7e8e519ae28c2b
|
7
|
+
data.tar.gz: bde27862fbeb2b281cb23f8504bb16fe603ff59ed4401765128566b46e024a3afc66684f88ff07c4c0932de08f83fde35d7b5e3f03c1665c799e0733959cb60f
|
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
|
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
|
@@ -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: @params[:'library-master'],
|
78
|
+
desktop: @params[:'library-desktop'],
|
79
|
+
web: @params[:'library-web']
|
80
|
+
}.compact.transform_values(&Pathname.method(:new))
|
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
|
|
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.2
|
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
|