halation 0.1.0 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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