halation 0.1.0 → 0.2.0

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.
@@ -0,0 +1,21 @@
1
+ module Halation
2
+ # A collection of methods to coerce values into a desired type.
3
+ class Coerce
4
+
5
+ # @return [String, nil]
6
+ def self.string(value)
7
+ value &&= value.to_s
8
+ end
9
+
10
+ # @return [Integer, nil]
11
+ def self.integer(value)
12
+ value &&= value.to_i
13
+ end
14
+
15
+ # @return [Boolean]
16
+ def self.boolean(value)
17
+ !!value
18
+ end
19
+
20
+ end
21
+ end
@@ -0,0 +1,53 @@
1
+ require 'yaml'
2
+ require_relative 'coerce'
3
+ require_relative 'config/camera'
4
+
5
+ module Halation
6
+ # Application-wide configuraton.
7
+ class Config
8
+ # :nodoc:
9
+ DEFAULT_CONFIG_PATH = File.expand_path("~/.halation/config.yml").freeze
10
+
11
+ attr_reader :artist
12
+ attr_reader :copyright
13
+ attr_reader :cameras
14
+
15
+ def initialize
16
+ reset
17
+ end
18
+
19
+ # Reset the configuration to default values.
20
+ def reset
21
+ @artist = nil
22
+ @copyright = nil
23
+ @cameras = []
24
+ end
25
+
26
+ # Load the configuration from a YAML file.
27
+ def load_file(file_path = nil)
28
+ file_path ||= File.expand_path("./config.yml") if File.exists?("./config.yml")
29
+ file_path ||= DEFAULT_CONFIG_PATH
30
+
31
+ reset
32
+
33
+ YAML.load_file(file_path).tap do |config|
34
+ @artist = Coerce.string(config["artist"])
35
+ @copyright = Coerce.string(config["copyright"])
36
+
37
+ (config["cameras"] || []).each do |camera|
38
+ @cameras << Camera.new(camera)
39
+ end
40
+ end
41
+ end
42
+
43
+ # @return [String] list of configuration settings.
44
+ def to_s
45
+ [
46
+ "Artist: #{@artist}",
47
+ "Copyright: #{@copyright}",
48
+ @cameras.map(&:to_s)
49
+ ].join("\n")
50
+ end
51
+
52
+ end
53
+ end
@@ -0,0 +1,42 @@
1
+ require_relative '../coerce'
2
+ require_relative 'lens'
3
+
4
+ module Halation
5
+ class Config
6
+ # A camera profile.
7
+ class Camera
8
+ # A user-created ID.
9
+ attr_reader :tag
10
+ attr_reader :make
11
+ attr_reader :model
12
+ attr_reader :lenses
13
+
14
+ def initialize(yaml)
15
+ @tag = Coerce.string(yaml["tag"])
16
+ @make = Coerce.string(yaml["make"])
17
+ @model = Coerce.string(yaml["model"])
18
+ @lenses = []
19
+
20
+ yaml["lenses"].each do |lens|
21
+ @lenses << Lens.new(lens)
22
+ end
23
+ end
24
+
25
+ # @return [String]
26
+ def to_s
27
+ "Camera\n" <<
28
+ [
29
+ "Tag: #{tag}",
30
+ "Make: #{make}",
31
+ "Model: #{model}",
32
+ @lenses.map(&:to_s).join("\n")
33
+ ]
34
+ .join("\n")
35
+ .lines
36
+ .map { |line| " #{line}" }
37
+ .join
38
+ end
39
+
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,32 @@
1
+ require_relative '../coerce'
2
+
3
+ module Halation
4
+ class Config
5
+ # A lens profile.
6
+ class Lens
7
+ # A user-created ID.
8
+ attr_reader :tag
9
+ attr_reader :model
10
+ attr_reader :focal_length
11
+
12
+ def initialize(yaml)
13
+ @tag = Coerce.string(yaml["tag"])
14
+ @model = Coerce.string(yaml["model"])
15
+ @focal_length = Coerce.integer(yaml["focal_length"])
16
+ end
17
+
18
+ # @return [String]
19
+ def to_s
20
+ "Lens\n" <<
21
+ [
22
+ "Tag: #{tag}",
23
+ "Model: #{model}",
24
+ "Focal Length: #{focal_length}",
25
+ ]
26
+ .map { |line| " #{line}" }
27
+ .join("\n")
28
+ end
29
+
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,91 @@
1
+ require_relative 'config'
2
+ require_relative 'roll'
3
+ require_relative 'tools/exif_tool/image'
4
+
5
+ module Halation
6
+ # Performs modifications to the image Exif data.
7
+ class Engine
8
+ # Array of image extensions to search for.
9
+ attr_accessor :image_extensions
10
+ # Suppress output to stdout if true.
11
+ attr_accessor :silent
12
+
13
+ attr_reader :config
14
+ attr_reader :roll
15
+
16
+ # @see #run
17
+ def self.run(opts = {})
18
+ new(opts).run
19
+ end
20
+
21
+ # @option opts [String] :config_path (nil)
22
+ # Override the config file that should be used.
23
+ # @option opts [String] :working_dir (".")
24
+ # Override the working directory (contains images and roll.yml).
25
+ # @option opts [Array<String>] :image_extensions
26
+ # Override the list of image extensions to search for.
27
+ # @option opts [Boolean] :silent
28
+ # Suppress output to stdout.
29
+ def initialize(opts = {})
30
+ config_path = opts[:config_path]
31
+ @silent = !!opts[:silent]
32
+ @working_dir = File.expand_path(opts[:working_dir] || ".")
33
+ @image_extensions = opts[:image_extensions] || ["tif", "tiff", "jpg", "jpeg"]
34
+
35
+ @config = Config.new
36
+ @config.load_file(config_path)
37
+
38
+ @roll = Roll.new
39
+ @roll.load_file("#{@working_dir}/roll.yml")
40
+ end
41
+
42
+ # @return [Array<String>] detected image files to process, in ascending
43
+ # alphabetical order.
44
+ def image_files
45
+ Dir[*@image_extensions.map { |ext| "#{@working_dir}/*.#{ext}" }]
46
+ .sort[0...@roll.frames.count]
47
+ end
48
+
49
+ # Process the batch of images.
50
+ def run
51
+ _image_files = image_files
52
+
53
+ specified_camera = @roll.camera
54
+ camera = @config.cameras.find { |camera| camera.tag == specified_camera }
55
+ raise "Camera #{specified_camera} not found in config.yml" unless camera
56
+
57
+ @roll.frames.each_with_index do |frame, i|
58
+ break if i >= _image_files.count
59
+
60
+ specified_lens = frame.lens || @roll.lens
61
+ lens = camera.lenses.find { |lens| lens.tag == specified_lens }
62
+ raise "Lens #{specified_lens} not found for frame #{frame.number}" unless lens
63
+
64
+ relative_path = _image_files[i].gsub(%r(^#{Dir.pwd}/), "")
65
+ puts "Processing frame #{frame.number}\n #{relative_path}" unless @silent
66
+
67
+ ExifToolImage.new(_image_files[i]).tap do |exif|
68
+ exif["Artist"] = @roll.artist || @config.artist
69
+ exif["Copyright"] = @roll.copyright || @config.copyright
70
+
71
+ date_created = Time.new(frame.date || @roll.date)
72
+ exif["DateTimeOriginal"] = date_created
73
+ exif["CreateDate"] = date_created
74
+
75
+ exif["Make"] = camera.make
76
+ exif["Model"] = camera.model
77
+ exif["LensModel"] = lens.model
78
+ exif["ISO"] = @roll.iso
79
+ exif["ExposureTime"] = frame.shutter || @roll.shutter
80
+ exif["FNumber"] = frame.aperture || @roll.aperture
81
+ exif["FocalLength"] = frame.focal_length || lens.focal_length || @roll.focal_length
82
+ exif["Flash"] = frame.flash ? 1 : 0
83
+ exif["Orientation"] = frame.orientation || 1
84
+
85
+ exif.save
86
+ end
87
+ end
88
+ end
89
+
90
+ end
91
+ end
@@ -0,0 +1,57 @@
1
+ require 'yaml'
2
+ require_relative 'coerce'
3
+ require_relative 'roll/frame'
4
+
5
+ module Halation
6
+ # Settings for a roll of film.
7
+ class Roll
8
+ # Artist for the roll of film.
9
+ attr_reader :artist
10
+ # Copyright for the roll of film.
11
+ attr_reader :copyright
12
+ # Default date for all frames (optional).
13
+ attr_reader :date
14
+ # Tag of the cameara used.
15
+ attr_reader :camera
16
+ # Tag of the default lens used (optional).
17
+ attr_reader :lens
18
+ # ISO of the roll of film.
19
+ attr_reader :iso
20
+ # Array of frames on the roll of film.
21
+ attr_reader :frames
22
+
23
+ def initialize
24
+ reset
25
+ end
26
+
27
+ # Reset the configuration to default values.
28
+ def reset
29
+ @artist = nil
30
+ @copyright = nil
31
+ @date = nil
32
+ @camera = nil
33
+ @lens = nil
34
+ @iso = nil
35
+ @frames = []
36
+ end
37
+
38
+ # Load the settings from a YAML file.
39
+ def load_file(file_path)
40
+ reset
41
+
42
+ YAML.load_file(file_path).tap do |roll|
43
+ @artist = Coerce.string(roll["artist"])
44
+ @copyright = Coerce.string(roll["copyright"])
45
+ @date = Coerce.string(roll["date"])
46
+ @camera = Coerce.string(roll["camera"])
47
+ @lens = Coerce.string(roll["lens"])
48
+ @iso = Coerce.integer(roll["iso"])
49
+
50
+ (roll["frames"] || []).each do |frame|
51
+ @frames << Frame.new(frame)
52
+ end
53
+ end
54
+ end
55
+
56
+ end
57
+ end
@@ -0,0 +1,47 @@
1
+ require_relative '../coerce'
2
+
3
+ module Halation
4
+ class Roll
5
+ # A frame on the roll of film.
6
+ class Frame
7
+ # Frame number.
8
+ attr_reader :number
9
+ # Date the frame was taken.
10
+ attr_reader :date
11
+ # Tag of the lens used.
12
+ attr_reader :lens
13
+ # Focal length of the lens, if not specified by the lens profile
14
+ # (a zoom lens).
15
+ attr_reader :focal_length
16
+ # @todo spec
17
+ # Shutter speed.
18
+ # @example "1/125", "0.5" (half second), "15" (seconds), "120" (2 minutes)
19
+ attr_reader :shutter
20
+ # F-number of the aperture setting.
21
+ attr_reader :aperture
22
+ # True if flash was fired.
23
+ attr_reader :flash
24
+ # 1 = Horizontal (normal)
25
+ # 2 = Mirror horizontal
26
+ # 3 = Rotate 180
27
+ # 4 = Mirror vertical
28
+ # 5 = Mirror horizontal and rotate 270 CW
29
+ # 6 = Rotate 90 CW
30
+ # 7 = Mirror horizontal and rotate 90 CW
31
+ # 8 = Rotate 270 CW
32
+ attr_reader :orientation
33
+
34
+ def initialize(yaml)
35
+ @number = Coerce.integer(yaml["number"])
36
+ @date = Coerce.string(yaml["date"])
37
+ @lens = Coerce.string(yaml["lens"])
38
+ @focal_length = Coerce.integer(yaml["focal_length"])
39
+ @shutter = Coerce.string(yaml["shutter"])
40
+ @aperture = Coerce.string(yaml["aperture"])
41
+ @flash = Coerce.boolean(yaml["flash"])
42
+ @orientation = Coerce.integer(yaml["orientation"])
43
+ end
44
+
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,150 @@
1
+ require 'optparse'
2
+ require 'fileutils'
3
+
4
+ require_relative 'engine'
5
+
6
+ module Halation
7
+ # The script that runs when the halation binary is executed.
8
+ class Script
9
+ private_class_method :new
10
+
11
+ # @option opts [Boolean] :args (ARGV)
12
+ # @option opts [Boolean] :output_stream (STDOUT)
13
+ # @option opts [Boolean] :skip_exit (false)
14
+ # Don't exit the program after calling a handler that would normally exit.
15
+ # Used for unit testing.
16
+ def self.run(opts = {})
17
+ args = opts[:args] || ARGV
18
+ output_stream = opts[:output_stream] || STDOUT
19
+ skip_exit = !!opts[:skip_exit]
20
+ run_engine = true
21
+
22
+ options = {}
23
+
24
+ OptionParser.new { |opts|
25
+ opts.banner = "Usage: halation [options]"
26
+
27
+ opts.on("-c", "--config=PATH", String, "Config file path") do |config_path|
28
+ options[:config_path] = config_path
29
+ end
30
+
31
+ opts.on("--dry", "Dry run") do
32
+ options[:dry_run] = true
33
+ # TODO: Implement
34
+ raise NotImplementedError, "Dry run option is not yet implemented."
35
+ end
36
+
37
+ opts.on("-h", "--help", "Print this help") do
38
+ output_stream.puts opts
39
+ run_engine = false
40
+ exit unless skip_exit
41
+ end
42
+
43
+ opts.on("--new-config", "Generate a new config file") do |path|
44
+ # TODO: Implement
45
+ raise NotImplementedError, "Generate config option is not yet implemented."
46
+ run_engine = false
47
+ exit unless skip_exit
48
+ end
49
+
50
+ opts.on("--new-roll", "Generate a new roll.yml file") do
51
+ generate_new_roll
52
+ run_engine = false
53
+ exit unless skip_exit
54
+ end
55
+
56
+ opts.on("-p", "--print-config", "Print the configuration settings") do
57
+ # TODO: Implement
58
+ raise NotImplementedError, "Print config option is not yet implemented."
59
+ run_engine = false
60
+ exit unless skip_exit
61
+ end
62
+
63
+ opts.on("-r", "--recursive", "Traverse into subdirectories") do
64
+ # TODO: Implement
65
+ raise NotImplementedError, "Recursive option is not yet implemented."
66
+ end
67
+
68
+ opts.on("--silent", "Suppress messages to stdout.") do
69
+ options[:silent] = true
70
+ end
71
+
72
+ opts.on("-v", "--version", "Print the version information") do
73
+ output_stream.puts "halation #{Halation::VERSION}"
74
+ run_engine = false
75
+ exit unless skip_exit
76
+ end
77
+ }.parse!(args)
78
+
79
+ Halation::Engine.run(options) if run_engine
80
+ end
81
+
82
+ # Generate a new roll.yml file.
83
+ # Copies "~/.halation/templates/roll.yml" if it exists, otherwise it uses
84
+ # a default template.
85
+ def self.generate_new_roll
86
+ roll_path = "roll.yml"
87
+
88
+ if File.exists?(roll_path)
89
+ output_stream.puts "A roll.yml file already exists in this directory."
90
+ return
91
+ end
92
+
93
+ # TODO: Make this configurable from config.yml
94
+ roll_template_path = File.expand_path("~/.halation/templates/roll.yml")
95
+
96
+ if File.exists?(roll_template_path)
97
+ FileUtils.cp(roll_template_path, ".")
98
+ else
99
+ File.open(roll_path, "w") do |f|
100
+ f.puts new_roll_content
101
+ end
102
+ end
103
+ end
104
+
105
+ private
106
+
107
+ # @return [String] roll.yml default content
108
+ def self.new_roll_content
109
+ output = <<YAML
110
+ ---
111
+ date: "2016-01-01"
112
+ camera: "rz67"
113
+ lens: 110
114
+ iso: 100
115
+ frames:
116
+ - number: 1
117
+ shutter: "1/125"
118
+ aperture: 8
119
+ - number: 2
120
+ shutter: "1/125"
121
+ aperture: 8
122
+ - number: 3
123
+ shutter: "1/125"
124
+ aperture: 8
125
+ - number: 4
126
+ shutter: "1/125"
127
+ aperture: 8
128
+ - number: 5
129
+ shutter: "1/125"
130
+ aperture: 8
131
+ - number: 6
132
+ shutter: "1/125"
133
+ aperture: 8
134
+ - number: 7
135
+ shutter: "1/125"
136
+ aperture: 8
137
+ - number: 8
138
+ shutter: "1/125"
139
+ aperture: 8
140
+ - number: 9
141
+ shutter: "1/125"
142
+ aperture: 8
143
+ - number: 10
144
+ shutter: "1/125"
145
+ aperture: 8
146
+ YAML
147
+ end
148
+
149
+ end
150
+ end