proofsheet 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dfb63ef244e4dc5bd439965363d028bb5760f1d76cd4b04b9595278e85990cd5
4
- data.tar.gz: '0699294e136dc91a4afa0b718b6ddf07626261c9cc6bcb6cdb756e3563679fdb'
3
+ metadata.gz: 32d08a5e2dc65281e9d912c3c1fbf1d99c62b6dbeada3292f1460d4f9a03f866
4
+ data.tar.gz: 9eed758acf8fbba11febe56cb65ccd4c880f851301eaac1e5f1e82bd7845d7b4
5
5
  SHA512:
6
- metadata.gz: '0387c6a7d3fe71a573e937f0df9103c26646218f6f5f1283c7ab78fc72b4b3ec6b26d0361156c6280f9d95355ee21c8321dac89d33f3459ed4435f9f683da64e'
7
- data.tar.gz: ad13fdcaf9caa12d724e54abaac56b096288a34f76881f15ff49ef008535911c6fbe7a334f653af89b10adee1cc3ef513c2b326aa68bfbc21f7efd3b46d06424
6
+ metadata.gz: a4691597bd56de3061f2767887c05b8ad3d23f8460b5d6ccce808c2162f5a2c70bfeb31d82b08c64389b9edf454010beb1244b493f4f4c8bb4ee6f475faae200
7
+ data.tar.gz: f398d39c015136879e4b0b4aad78ba34907e67713bae18d0dedf23392466993fc0e5652d97cc8bfb018c4d4e35043950f375c0c137144580eed7594b34075ee9
data/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.2.0] - 2026-09-19
4
+
5
+ - Add the `compose` command for arranging captured screenshots on solid, transparent, gradient, or image backgrounds
6
+ - Add per-layer cropping, proportional resizing, rounded corners, rotation, and shadows
7
+
3
8
  ## [0.1.0] - 2026-09-09
4
9
 
5
10
  - Initial release
data/README.md CHANGED
@@ -106,6 +106,56 @@ proofsheet list
106
106
 
107
107
  Relative output paths are resolved from the directory where the command runs.
108
108
 
109
+ ## Compositions
110
+
111
+ Compositions arrange captured shots on a new PNG canvas. A background can be a solid color, an angled two-color gradient, or an image. Layers can be positioned, resized proportionally, rounded, rotated, and given a shadow.
112
+
113
+ ```yaml
114
+ compositions:
115
+ launch-hero:
116
+ canvas: [1600, 1000]
117
+ background:
118
+ gradient: ["#211002", "#5aa579"]
119
+ angle: 135
120
+ layers:
121
+ - shot: dashboard
122
+ x: 120
123
+ y: 140
124
+ crop:
125
+ x: 80
126
+ y: 40
127
+ width: 1200
128
+ height: 700
129
+ width: 980
130
+ radius: 32
131
+ rotate: -4
132
+ shadow:
133
+ x: 8
134
+ y: 24
135
+ blur: 32
136
+ color: "#00000066"
137
+ - shot: activity
138
+ x: 900
139
+ y: 460
140
+ width: 560
141
+ rotate: 5
142
+ shadow: true
143
+ ```
144
+
145
+ Solid colors can be written directly as `background: "#fff8f5"`. For a transparent canvas, use `background: "#00000000"`. For an image background, use `background: { image: "assets/background.png" }`; Proofsheet scales and center-crops it to cover the canvas. Colors use `#RRGGBB` or `#RRGGBBAA` notation.
146
+
147
+ Layer crop coordinates are measured in pixels from the original captured shot. Proofsheet crops first, then resizes proportionally using `width`, rounds corners using `radius`, rotates, adds the shadow, and places the result on the canvas. The radius is measured in pixels after resizing and is clamped to half the layer's smaller dimension.
148
+
149
+ Setting `shadow: true` uses `x: 0`, `y: 16`, `blur: 24`, and `color: "#00000055"`. Use the expanded shadow mapping shown above to override any of those values.
150
+
151
+ Capture the source shots, then compose every configured image or selected compositions:
152
+
153
+ ```sh
154
+ proofsheet capture
155
+ proofsheet compose
156
+ proofsheet compose launch-hero
157
+ ```
158
+
109
159
  ## Development
110
160
 
111
161
  Run `bin/setup`, then `bundle exec rake` to run the specs and linter. To install this gem locally, run `bundle exec rake install`.
@@ -4,29 +4,30 @@ require "optparse"
4
4
 
5
5
  module Proofsheet
6
6
  class CLI
7
- USAGE = "Usage: proofsheet <capture [SHOT ...] | list> [--config PATH] [--host HOST]"
7
+ USAGE = "Usage: proofsheet <capture [SHOT ...] | compose [COMPOSITION ...] | list> " \
8
+ "[--config PATH] [--host HOST]"
8
9
 
9
- def initialize(argv, out: $stdout, err: $stderr, root: Dir.pwd, manifest_loader: Manifest.method(:load),
10
- capturer_class: Capturer)
10
+ def initialize(argv, out: $stdout, err: $stderr, root: Dir.pwd, manifest_loader: Manifest.method(:load), **classes)
11
11
  @argv = argv.dup
12
12
  @out = out
13
13
  @err = err
14
14
  @root = root
15
15
  @manifest_loader = manifest_loader
16
- @capturer_class = capturer_class
16
+ @capturer_class = classes.fetch(:capturer_class, Capturer)
17
+ @composer_class = classes.fetch(:composer_class, Composer)
17
18
  end
18
19
 
19
20
  def run
20
21
  command = @argv.shift
21
22
  return help(0) if %w[help --help -h].include?(command)
22
23
  return version if %w[--version -v].include?(command)
23
- return help(1) unless %w[capture list].include?(command)
24
+ return help(1) unless %w[capture compose list].include?(command)
24
25
 
25
26
  options = { config: Manifest::DEFAULT_PATH }
26
27
  parser(options).parse!(@argv)
27
28
  manifest = @manifest_loader.call(options[:config])
28
29
 
29
- command == "list" ? list(manifest) : capture(manifest, options)
30
+ dispatch(command, manifest, options)
30
31
  0
31
32
  rescue OptionParser::ParseError, Error, KeyError => e
32
33
  @err.puts "proofsheet: #{e.message}"
@@ -35,6 +36,13 @@ module Proofsheet
35
36
 
36
37
  private
37
38
 
39
+ def dispatch(command, manifest, options)
40
+ return list(manifest) if command == "list"
41
+ return compose(manifest) if command == "compose"
42
+
43
+ capture(manifest, options)
44
+ end
45
+
38
46
  def parser(options)
39
47
  OptionParser.new do |opts|
40
48
  opts.on("-c", "--config PATH") { |path| options[:config] = path }
@@ -61,6 +69,13 @@ module Proofsheet
61
69
  @out.puts "Done."
62
70
  end
63
71
 
72
+ def compose(manifest)
73
+ names = @argv.empty? ? manifest.composition_names : @argv
74
+ @out.puts "Composing #{names.size} #{names.size == 1 ? "image" : "images"}"
75
+ @composer_class.new(manifest: manifest, root: @root, out: @out).compose(names)
76
+ @out.puts "Done."
77
+ end
78
+
64
79
  def help(status)
65
80
  (status.zero? ? @out : @err).puts USAGE
66
81
  status
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "pathname"
5
+ require "vips"
6
+
7
+ module Proofsheet
8
+ class Composer
9
+ def initialize(manifest:, root: Dir.pwd, out: $stdout)
10
+ @manifest = manifest
11
+ @root = Pathname(root)
12
+ @out = out
13
+ end
14
+
15
+ def compose(names = @manifest.composition_names)
16
+ names.each { |name| compose_one(@manifest.composition(name)) }
17
+ end
18
+
19
+ private
20
+
21
+ def compose_one(composition)
22
+ @out.puts composition.name
23
+ image = background_image(composition)
24
+ composition.layers.each { |layer| image = add_layer(image, layer) }
25
+ write(composition, image)
26
+ end
27
+
28
+ def background_image(composition)
29
+ spec = composition.background
30
+ return solid(composition.width, composition.height, spec.color) if spec.color
31
+ return gradient(composition.width, composition.height, spec.gradient, spec.angle) if spec.gradient
32
+ return cover_image(spec.image, composition.width, composition.height) if spec.image
33
+
34
+ raise Error, "#{composition.name}: background must set color, gradient, or image"
35
+ end
36
+
37
+ def add_layer(canvas, layer)
38
+ image = layer_image(layer)
39
+ canvas = add_shadow(canvas, image, layer) if layer.shadow
40
+ canvas.composite2(image, :over, x: layer.x, y: layer.y)
41
+ end
42
+
43
+ def layer_image(layer)
44
+ image = load_image(output_path("#{layer.shot}.png"))
45
+ image = Cropper.call(image, layer.crop) if layer.crop
46
+ image = image.resize(layer.width.to_f / image.width) if layer.width
47
+ image = RoundedCorners.call(image, layer.radius)
48
+ image = image.rotate(layer.rotate, background: [0, 0, 0, 0]) unless layer.rotate.zero?
49
+ image
50
+ end
51
+
52
+ def add_shadow(canvas, image, layer)
53
+ shadow = layer.shadow
54
+ alpha, padding = shadow_mask(image, shadow.blur)
55
+ overlay = shadow_overlay(alpha, shadow.color)
56
+ canvas.composite2(overlay, :over, x: layer.x + shadow.x - padding,
57
+ y: layer.y + shadow.y - padding)
58
+ end
59
+
60
+ def shadow_mask(image, blur)
61
+ padding = (blur * 3).ceil
62
+ alpha = image.extract_band(3).embed(padding, padding, image.width + (padding * 2),
63
+ image.height + (padding * 2), extend: :black)
64
+ alpha = alpha.gaussblur(blur) if blur.positive?
65
+ [alpha, padding]
66
+ end
67
+
68
+ def shadow_overlay(alpha, value)
69
+ color = parse_color(value)
70
+ opacity = color.pop
71
+ rgb = solid(alpha.width, alpha.height, color).extract_band(0, n: 3)
72
+ rgb.bandjoin(alpha * (opacity / 255.0))
73
+ end
74
+
75
+ def solid(width, height, color)
76
+ channels = color.is_a?(Array) ? color : parse_color(color)
77
+ rgb = Vips::Image.black(width, height).new_from_image(channels.first(3)).copy(interpretation: :srgb)
78
+ rgb.bandjoin(channels.fetch(3, 255))
79
+ end
80
+
81
+ def gradient(width, height, colors, angle)
82
+ raise Error, "gradient must contain exactly two colors" unless colors&.size == 2
83
+
84
+ from, to = colors.map { |color| parse_color(color) }
85
+ amount = gradient_amount(width, height, angle)
86
+ channels = from.zip(to).map { |start, finish| (amount * (finish - start)) + start }
87
+ channels.shift.bandjoin(channels).cast(:uchar).copy(interpretation: :srgb)
88
+ end
89
+
90
+ def gradient_amount(width, height, degrees)
91
+ coordinates = Vips::Image.xyz(width, height)
92
+ radians = degrees * Math::PI / 180
93
+ projection = (coordinates[0] * Math.cos(radians)) + (coordinates[1] * Math.sin(radians))
94
+ minimum = projection.min
95
+ maximum = projection.max
96
+ return Vips::Image.black(width, height) if minimum == maximum
97
+
98
+ (projection - minimum) / (maximum - minimum)
99
+ end
100
+
101
+ def cover_image(path, width, height)
102
+ image = load_image(@root.join(path))
103
+ scale = [width.to_f / image.width, height.to_f / image.height].max
104
+ image = image.resize(scale)
105
+ image.crop((image.width - width) / 2, (image.height - height) / 2, width, height)
106
+ end
107
+
108
+ def load_image(path)
109
+ raise Error, "image not found: #{path}" unless path.exist?
110
+
111
+ image = Vips::Image.new_from_file(path.to_s, access: :sequential)
112
+ image = image.colourspace(:srgb) unless image.interpretation == :srgb
113
+ image.has_alpha? ? image : image.bandjoin(255)
114
+ end
115
+
116
+ def parse_color(value)
117
+ match = /\A#([0-9a-f]{6})([0-9a-f]{2})?\z/i.match(value.to_s)
118
+ raise Error, "invalid color #{value.inspect}; use #RRGGBB or #RRGGBBAA" unless match
119
+
120
+ (match[1].scan(/../) + [match[2] || "ff"]).map { |channel| channel.to_i(16) }
121
+ end
122
+
123
+ def output_path(filename) = @root.join(@manifest.output_dir, filename)
124
+
125
+ def write(composition, image)
126
+ path = output_path(composition.filename)
127
+ FileUtils.mkdir_p(path.dirname)
128
+ image.write_to_file(path.to_s)
129
+ @out.puts " wrote #{path.relative_path_from(@root)}"
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proofsheet
4
+ class CompositionConfig
5
+ Background = Data.define(:color, :gradient, :image, :angle)
6
+ Shadow = Data.define(:x, :y, :blur, :color)
7
+ Crop = Data.define(:x, :y, :width, :height)
8
+ Layer = Data.define(:shot, :x, :y, :crop, :width, :radius, :rotate, :shadow)
9
+ Composition = Data.define(:name, :width, :height, :background, :layers) do
10
+ def filename
11
+ "#{name}.png"
12
+ end
13
+ end
14
+
15
+ def initialize(data, path:)
16
+ @data = data
17
+ @path = path
18
+ end
19
+
20
+ def names
21
+ @data.keys
22
+ end
23
+
24
+ def fetch(name)
25
+ raw = @data[name]
26
+ raise Error, unknown_message(name) if raw.nil?
27
+
28
+ width, height = raw.fetch("canvas")
29
+ Composition.new(name: name, width: width, height: height,
30
+ background: background(raw.fetch("background", "#ffffff")),
31
+ layers: raw.fetch("layers").map { |layer| layer_spec(layer) })
32
+ rescue KeyError => e
33
+ raise Error, "#{name}: #{e.message} in #{@path}"
34
+ end
35
+
36
+ private
37
+
38
+ def unknown_message(name)
39
+ "unknown composition #{name.inspect} — known compositions: #{names.join(", ")}"
40
+ end
41
+
42
+ def background(raw)
43
+ return Background.new(color: raw, gradient: nil, image: nil, angle: 0) if raw.is_a?(String)
44
+
45
+ Background.new(color: raw["color"], gradient: raw["gradient"], image: raw["image"],
46
+ angle: raw.fetch("angle", 0))
47
+ end
48
+
49
+ def layer_spec(raw)
50
+ Layer.new(shot: raw.fetch("shot"), x: raw.fetch("x", 0), y: raw.fetch("y", 0),
51
+ crop: crop(raw["crop"]), width: raw["width"], radius: raw.fetch("radius", 0),
52
+ rotate: raw.fetch("rotate", 0), shadow: shadow(raw["shadow"]))
53
+ end
54
+
55
+ def crop(raw)
56
+ return if raw.nil?
57
+
58
+ Crop.new(x: raw.fetch("x"), y: raw.fetch("y"),
59
+ width: raw.fetch("width"), height: raw.fetch("height"))
60
+ end
61
+
62
+ def shadow(raw)
63
+ return if raw.nil? || raw == false
64
+
65
+ raw = {} if raw == true
66
+ Shadow.new(x: raw.fetch("x", 0), y: raw.fetch("y", 16), blur: raw.fetch("blur", 24),
67
+ color: raw.fetch("color", "#00000055"))
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proofsheet
4
+ class Cropper
5
+ def self.call(image, spec)
6
+ new(image, spec).call
7
+ end
8
+
9
+ def initialize(image, spec)
10
+ @image = image
11
+ @spec = spec
12
+ end
13
+
14
+ def call
15
+ raise Error, "crop #{@spec.to_h} exceeds source image #{@image.width}×#{@image.height}" unless valid?
16
+
17
+ @image.crop(@spec.x, @spec.y, @spec.width, @spec.height)
18
+ end
19
+
20
+ private
21
+
22
+ def valid?
23
+ @spec.x >= 0 && @spec.y >= 0 && positive_dimensions? && within_bounds?
24
+ end
25
+
26
+ def positive_dimensions?
27
+ @spec.width.positive? && @spec.height.positive?
28
+ end
29
+
30
+ def within_bounds?
31
+ @spec.x + @spec.width <= @image.width && @spec.y + @spec.height <= @image.height
32
+ end
33
+ end
34
+ end
@@ -73,6 +73,12 @@ module Proofsheet
73
73
  names.map { |name| shot(name) }
74
74
  end
75
75
 
76
+ def composition_names = composition_config.names
77
+
78
+ def composition(name)
79
+ composition_config.fetch(name)
80
+ end
81
+
76
82
  def shot(name)
77
83
  raw = shot_data[name]
78
84
  raise Error, "unknown shot #{name.inspect} — known shots: #{names.join(", ")}" if raw.nil?
@@ -124,5 +130,9 @@ module Proofsheet
124
130
  rescue KeyError
125
131
  raise Error, "shots is missing from #{@path}"
126
132
  end
133
+
134
+ def composition_config
135
+ @composition_config ||= CompositionConfig.new(@data.fetch("compositions", {}), path: @path)
136
+ end
127
137
  end
128
138
  end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proofsheet
4
+ class RoundedCorners
5
+ def self.call(image, requested_radius)
6
+ return image unless requested_radius.positive?
7
+
8
+ new(image, requested_radius).call
9
+ end
10
+
11
+ def initialize(image, requested_radius)
12
+ @image = image
13
+ @radius = [requested_radius.to_f, image.width / 2.0, image.height / 2.0].min
14
+ end
15
+
16
+ def call
17
+ alpha = (@image.extract_band(3) * (coverage / 255.0)).cast(:uchar)
18
+ @image.extract_band(0, n: 3).bandjoin(alpha)
19
+ end
20
+
21
+ private
22
+
23
+ def coverage
24
+ distance = ((x_distance * x_distance) + (y_distance * y_distance))**0.5
25
+ value = (((distance * -1) + @radius + 0.5) * 255)
26
+ value = value.relational_const(:more, [255]).ifthenelse(255, value)
27
+ value.relational_const(:less, [0]).ifthenelse(0, value)
28
+ end
29
+
30
+ def x_distance
31
+ corner_distance(coordinates[0], @image.width)
32
+ end
33
+
34
+ def y_distance
35
+ corner_distance(coordinates[1], @image.height)
36
+ end
37
+
38
+ def corner_distance(coordinate, length)
39
+ distance = (coordinate - ((length - 1) / 2.0)).abs - ((length / 2.0) - @radius)
40
+ distance.relational_const(:more, [0]).ifthenelse(distance, 0)
41
+ end
42
+
43
+ def coordinates
44
+ @coordinates ||= Vips::Image.xyz(@image.width, @image.height)
45
+ end
46
+ end
47
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Proofsheet
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/proofsheet.rb CHANGED
@@ -10,6 +10,10 @@ end
10
10
  require_relative "proofsheet/browser"
11
11
  require_relative "proofsheet/clip"
12
12
  require_relative "proofsheet/credentials"
13
+ require_relative "proofsheet/composition_config"
13
14
  require_relative "proofsheet/manifest"
14
15
  require_relative "proofsheet/capturer"
16
+ require_relative "proofsheet/cropper"
17
+ require_relative "proofsheet/rounded_corners"
18
+ require_relative "proofsheet/composer"
15
19
  require_relative "proofsheet/cli"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: proofsheet
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Marc Heiligers
@@ -70,8 +70,12 @@ files:
70
70
  - lib/proofsheet/capturer.rb
71
71
  - lib/proofsheet/cli.rb
72
72
  - lib/proofsheet/clip.rb
73
+ - lib/proofsheet/composer.rb
74
+ - lib/proofsheet/composition_config.rb
73
75
  - lib/proofsheet/credentials.rb
76
+ - lib/proofsheet/cropper.rb
74
77
  - lib/proofsheet/manifest.rb
78
+ - lib/proofsheet/rounded_corners.rb
75
79
  - lib/proofsheet/version.rb
76
80
  - sig/proofsheet.rbs
77
81
  homepage: https://github.com/FASCINATION-works/proofsheet