calmlens 0.18.3 → 0.20.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,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+ require_relative 'classification'
5
+ require_relative 'test_input_scenario'
6
+ require_relative 'test_image_grid'
7
+
8
+ module CalmLens
9
+ # Signed test inputs: text codes, test images and media phrases that a project's moderation
10
+ # recognises as a declared scenario when they are signed with its secret test input key (shown in
11
+ # the portal's project settings), so CalmLens returns that scenario instead of classifying them.
12
+ # The formats match the JavaScript SDK's `calmlens/test-inputs` byte for byte.
13
+ #
14
+ # A scenario is a Hash with the API's keys, as Strings or Symbols (snake_case keys are converted
15
+ # to camelCase like other options):
16
+ # `{ 'regionalActions' => [{ 'region' => 'DE', 'action' => 'block' }], 'unassessedCategories' => ['profanity'],
17
+ # 'instances' => [{ 'category' => 'harassment', 'score' => 0.9, 'action' => 'block', 'qualifiers' => ['harassment-abuse'] }] }`.
18
+ # `instances` (at most 128) and each instance's `qualifiers` (unique qualifiers of its category, at
19
+ # most 64; input-type qualifiers such as `sexual-text` are automatic) are required; `score` (0 to
20
+ # 1) and `action` (`allow`, `limit`, `age-restrict` or `block`, on every instance or none) are
21
+ # optional. `unassessedCategories` lists categories reported as not assessed, none with instances;
22
+ # `regionalActions` gives up to one `limit`, `age-restrict` or `block` action for each of
23
+ # {REGIONS}. Values are Strings and Numerics. An absent list is left out of the signed scenario;
24
+ # an empty one is sent as `[]`. Anything else raises `sdk/invalid-argument`.
25
+ #
26
+ # @example
27
+ # code = CalmLens::TestInputs.generate_text_code('harassment.insult', test_input_key)
28
+ # png = CalmLens::TestInputs.paint_image('violence', test_input_key).png
29
+ # phrase = CalmLens::TestInputs.generate_media_code('violence', test_input_key)
30
+ module TestInputs
31
+ # A painted test image: `width` by `height` pixels of 8-bit RGBA, row by row, in the binary
32
+ # String `rgba`, and the same image as binary PNG bytes in `png`.
33
+ Image = Struct.new(:width, :height, :rgba, :png, keyword_init: true)
34
+
35
+ # The regions a regional action can name.
36
+ REGIONS = %w[DE AT FR GB EU AU BR].freeze
37
+ # The signals {generate_text_code}, {paint_image} and {for_signal} accept: the categories and
38
+ # five named shortcuts.
39
+ SIGNALS = (MockClassification::CATEGORIES +
40
+ %w[sexual.involving-minors harassment.insult sexual.adult hate.regional-symbol violence.graphic]).freeze
41
+ # The signals {generate_media_code} accepts.
42
+ MEDIA_SIGNALS = (MockClassification::CATEGORIES + %w[violence.graphic]).freeze
43
+
44
+ one = ->(category, *qualifiers) { { 'instances' => [{ 'category' => category, 'qualifiers' => qualifiers }] } }
45
+ deep_freeze = lambda do |value|
46
+ value.each_value { |item| deep_freeze.call(item) } if value.is_a?(Hash)
47
+ value.each { |item| deep_freeze.call(item) } if value.is_a?(Array)
48
+ value.freeze
49
+ end
50
+ # Ready-made scenarios (deeply frozen): a clean input, a limited one, adult images and text,
51
+ # regional delivery, content restricted in Germany, and mixed instances in one category.
52
+ PRESETS = deep_freeze.call(
53
+ clean: { 'instances' => [] },
54
+ limited: one.call('harassment', 'harassment-abuse'),
55
+ adult_image: one.call('sexual', 'sexual-pornographic-media'),
56
+ adult_text: one.call('sexual', 'sexual-explicit-language'),
57
+ regional_delivery: { 'instances' => [], 'regionalActions' => [{ 'region' => 'DE', 'action' => 'block' },
58
+ { 'region' => 'GB', 'action' => 'age-restrict' },
59
+ { 'region' => 'FR', 'action' => 'limit' }] },
60
+ germany: one.call('hate', 'hate-symbol', 'hate-nazi-symbols-public-display'),
61
+ mixed: { 'instances' => [{ 'category' => 'harassment', 'qualifiers' => %w[harassment-abuse harassment-documentary] },
62
+ { 'category' => 'harassment', 'qualifiers' => ['harassment-threat'] }] }
63
+ )
64
+
65
+ HEX_WORDS = %w[zero one two three four five six seven eight nine alpha bravo charlie delta echo foxtrot].freeze
66
+ private_constant :HEX_WORDS
67
+
68
+ module_function
69
+
70
+ # The scenario a signal (one of {SIGNALS}) stands for in a text input (`kind` `'text'`) or an
71
+ # image (`'image'`), as a new Hash. {generate_text_code} uses the text kind and {paint_image}
72
+ # the image kind.
73
+ # @param signal [String]
74
+ # @param kind [String] `'text'` or `'image'`
75
+ # @return [Hash{String => Object}]
76
+ # @raise [InvalidArgumentError] `sdk/invalid-argument` for an unknown signal or kind
77
+ def for_signal(signal, kind = 'text')
78
+ raise Internal.invalid("Test input kind must be text or image (got #{kind.inspect})") unless %w[text image].include?(kind)
79
+
80
+ preset = case signal
81
+ when 'harassment.insult' then PRESETS[:limited]
82
+ when 'sexual.adult' then PRESETS[kind == 'image' ? :adult_image : :adult_text]
83
+ when 'hate.regional-symbol' then PRESETS[:germany]
84
+ when 'sexual.involving-minors' then { 'instances' => [{ 'category' => 'sexual', 'qualifiers' => ['sexual-minors'] }] }
85
+ when 'violence.graphic'
86
+ { 'instances' => [{ 'category' => 'violence', 'qualifiers' => %w[violence-injury violence-graphic] }] }
87
+ when *MockClassification::CATEGORIES then { 'instances' => [{ 'category' => signal, 'qualifiers' => [] }] }
88
+ else raise Internal.invalid("Unknown test signal #{signal.inspect}")
89
+ end
90
+ Marshal.load(Marshal.dump(preset))
91
+ end
92
+
93
+ # The signed text code for a scenario or a signal (one of {SIGNALS}): put it anywhere in a text
94
+ # input.
95
+ # @param input [Hash, String] a scenario or a signal
96
+ # @param test_input_key [String] the project's secret test input key from the portal
97
+ # @return [String]
98
+ # @raise [InvalidArgumentError] `sdk/invalid-argument` for an invalid key, scenario or signal
99
+ def generate_text_code(input, test_input_key)
100
+ text_code(scenario(input, 'text', test_input_key), test_input_key)
101
+ end
102
+
103
+ # Paints the signed test image for a scenario or a signal (one of {SIGNALS}): a grid that
104
+ # survives lossy formats and resizing. Upload its `png`, or encode its `rgba` in any format.
105
+ # @param input [Hash, String] a scenario or a signal
106
+ # @param test_input_key [String] the project's secret test input key from the portal
107
+ # @return [Image]
108
+ # @raise [InvalidArgumentError] `sdk/invalid-argument` for an invalid key, scenario or signal, or
109
+ # a scenario too large for a test image
110
+ def paint_image(input, test_input_key)
111
+ checked = scenario(input, 'image', test_input_key)
112
+ signature = text_code(checked, test_input_key).split[1]
113
+ content = Scenario.canonical_json(checked).b
114
+ data = [content.bytesize].pack('n') + [signature].pack('H*') + content
115
+ width, rgba = Grid.paint(data, checked['instances'].map { |instance| instance['category'] })
116
+ Image.new(width: width, height: width, rgba: rgba, png: Png.png(width, width, rgba))
117
+ end
118
+
119
+ # The phrase that triggers a signal (one of {MEDIA_SIGNALS}) in audio or video: read it aloud,
120
+ # or show it as large, high-contrast text in a frame.
121
+ # @param signal [String]
122
+ # @param test_input_key [String] the project's secret test input key from the portal
123
+ # @return [String]
124
+ # @raise [InvalidArgumentError] `sdk/invalid-argument` for an invalid key or signal
125
+ def generate_media_code(signal, test_input_key)
126
+ raise Internal.invalid("Unsupported media test signal #{signal.inspect}") unless MEDIA_SIGNALS.include?(signal)
127
+
128
+ check_key(test_input_key)
129
+ words = tag(test_input_key, "calmlens-test:media:#{signal}").each_char.map { |digit| HEX_WORDS[digit.to_i(16)] }
130
+ ['Calm Lens test', *words, signal.tr('.-', ' '), 'end test'].join(' ')
131
+ end
132
+
133
+ # The checked scenario for an input or signal, in canonical form.
134
+ def scenario(input, kind, test_input_key)
135
+ check_key(test_input_key)
136
+ Scenario.validate(input.is_a?(String) ? for_signal(input, kind) : input)
137
+ end
138
+
139
+ def text_code(checked, test_input_key)
140
+ payload = [Scenario.canonical_json(checked)].pack('m0').tr('+/', '-_').delete('=')
141
+ raise Internal.invalid('Test scenario is too large') if payload.bytesize > 8000
142
+
143
+ "<!calmlens-test #{tag(test_input_key, "calmlens-test:v1:#{payload}")} #{payload}>"
144
+ end
145
+
146
+ def check_key(key)
147
+ return if key.is_a?(String) && key.match?(/\A[a-f0-9]{32}\z/)
148
+
149
+ raise Internal.invalid('The test input key must be 32 lowercase hexadecimal characters')
150
+ end
151
+
152
+ # The first 32 hex characters of HMAC-SHA256 with the key's ASCII bytes.
153
+ def tag(key, message) = OpenSSL::HMAC.hexdigest('SHA256', key, message)[0, 32]
154
+
155
+ private_class_method :scenario, :text_code, :check_key, :tag
156
+ end
157
+ end
@@ -5,6 +5,7 @@ require 'securerandom'
5
5
  require 'uri'
6
6
  require_relative '../calmlens'
7
7
  require_relative 'testing/fake_api'
8
+ require_relative 'testing/test_inputs'
8
9
 
9
10
  module CalmLens
10
11
  # An offline client for tests. Calls run the real client's validation, encoding, retries and
@@ -63,6 +64,7 @@ module CalmLens
63
64
  retry explain wait_for_decision delete_content delete batch],
64
65
  folders: %i[list retrieve create update delete move],
65
66
  hash_lists: %i[list retrieve create update delete list_entries add_entry remove_entry],
67
+ policies: %i[list retrieve create update apply_preset set_automatic_updates delete assign],
66
68
  text: %i[classify], events: %i[list],
67
69
  cdn: %i[serve] }.freeze
68
70
  private_constant :OPERATIONS
@@ -126,7 +128,7 @@ module CalmLens
126
128
  -> { lock.synchronize { last = [(Time.now.to_f * 1000).to_i, last + 1].max } }
127
129
  end
128
130
 
129
- # @param project_id [String]
131
+ # @param project_id [String] `project-1` by default.
130
132
  # @param api_key [String] a key from {.create_api_key}; the default has no grants.
131
133
  # @param now [#call] returns the current time in epoch milliseconds. The default clock reads the
132
134
  # system time but advances at least 1 ms per reading, so records made in a row have strictly
@@ -144,7 +146,8 @@ module CalmLens
144
146
  # runs while the mock handles a request, so it must not call the mock.
145
147
  # @param max_retries [Integer] retries after a retryable failure; 0 by default, so a simulated
146
148
  # failure reaches the caller.
147
- # @param options [Hash] other {ServerClient#initialize} settings, except `api_key` and `transport`.
149
+ # @param options [Hash] other {ServerClient#initialize} settings, such as `part_size_bytes`, except
150
+ # `api_key` and `transport`.
148
151
  # Retries and decision checks wait no real time.
149
152
  def initialize(project_id: 'project-1', api_key: MockServerClient.create_api_key, base_url: 'https://api.calmlens.invalid/v1',
150
153
  cdn_base_url: 'https://cdn.calmlens.invalid/v1', now: MockServerClient.monotonic_clock,
@@ -225,6 +228,9 @@ module CalmLens
225
228
  raise Internal.invalid('retry_after must be a whole number of seconds')
226
229
  end
227
230
  raise Internal.invalid('details must be a Hash') unless details.nil? || details.is_a?(Hash)
231
+ if !(failure.is_a?(String) && ErrorCodes::STATUSES.key?(failure)) && !(details.nil? && retry_after.nil?)
232
+ raise Internal.invalid('details and retry_after apply only to an API error code')
233
+ end
228
234
 
229
235
  name = operation && operation.to_s.split('.').map { |part| Internal.camelize(part) }.join('.')
230
236
  if name && !OPERATION_NAMES.include?(name)
@@ -338,6 +344,8 @@ module CalmLens
338
344
  path, query = Internal.split_url(request.url).last.split('?', 2)
339
345
  base = request.url.start_with?("#{cdn_base_url}/") ? URI(cdn_base_url).path : URI(base_url).path
340
346
  call = Thread.current[@call_key]
347
+ # The later parts of a content upload sent in parts count as assets.uploadPart.
348
+ call = { operation: 'assets.uploadPart' } if request.method == 'POST' && path.delete_prefix(base).match?(%r{\A/assets/[^/]+/part\z})
341
349
  @mock_lock.synchronize do
342
350
  record = Request.new(method: request.method, url: request.url, path: decode_path(path.delete_prefix(base)),
343
351
  query: URI.decode_www_form(query.to_s).to_h, headers: request.headers.dup, body: body)
data/lib/calmlens/text.rb CHANGED
@@ -3,6 +3,9 @@
3
3
  module CalmLens
4
4
  # Text moderation shortcuts. Reach it through {ServerClient#text}.
5
5
  class Text < Resource
6
+ # The prefix of this resource's operations in Internal::OPTION_KEYS.
7
+ OPERATIONS = 'text'
8
+
6
9
  # @api private
7
10
  def initialize(session, assets)
8
11
  super(session)
@@ -17,11 +20,9 @@ module CalmLens
17
20
  # @return [Hash] the asset; show it to everyone only when `classification.moderation.status` is
18
21
  # `allowed` or `limited` (reduce the reach of `limited` content).
19
22
  def classify(options)
20
- api, request = call(options) { |*split| split }
23
+ api, request = call(options, __method__) { |*split| split }
21
24
  text = api['text']
22
- unless text.is_a?(String) && !text.strip.empty?
23
- raise Internal.invalid('Text to classify must be a non-empty string', ErrorCodes::INVALID_UPLOAD_PAYLOAD)
24
- end
25
+ raise Internal.invalid('Text to classify must be a non-empty string') unless text.is_a?(String) && !text.strip.empty?
25
26
 
26
27
  @assets.classify(without(api, 'text').merge('name' => api.fetch('name', 'text'),
27
28
  'content' => { 'plainText' => text, 'type' => 'text/plain' },
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CalmLens
4
+ # The CDN's rules for transforms: options checked against the image transform schema
5
+ # (Internal::TRANSFORM_OPTIONS_SCHEMA), and selectors read as the CDN reads them.
6
+ # @api private
7
+ module TransformRules
8
+ module_function
9
+
10
+ # Checks transform options against the image transform schema, as the CDN does, and returns
11
+ # the dotted keys it doesn't know and those whose values it refuses (or that are required and
12
+ # missing). nil options are left out of a selector, so they are not checked.
13
+ # @return [Array(Array<String>, Array<String>)] the unknown keys and the invalid keys
14
+ def check(options)
15
+ unknown = []
16
+ invalid = []
17
+ check_rule(Internal::TRANSFORM_OPTIONS_SCHEMA, options, '', unknown, invalid)
18
+ [unknown, invalid]
19
+ end
20
+
21
+ def check_rule(schema, value, key, unknown, invalid)
22
+ rule = schema.key?('oneOf') ? schema['oneOf'].find { |branch| rule_type?(branch['type'], value) } : schema
23
+ return invalid << key unless rule && rule_allows?(rule, value)
24
+ return unless rule['type'] == 'object'
25
+
26
+ path = ->(name) { key.empty? ? name : "#{key}.#{name}" }
27
+ entries = value.to_h { |name, item| [name.to_s, item] }
28
+ rule.fetch('required', []).each { |name| invalid << path.call(name) if entries[name].nil? }
29
+ entries.keys.sort.each do |name|
30
+ next if entries[name].nil?
31
+
32
+ child = rule.fetch('properties', {})[name]
33
+ child ? check_rule(child, entries[name], path.call(name), unknown, invalid) : unknown << path.call(name)
34
+ end
35
+ end
36
+
37
+ def rule_allows?(rule, value)
38
+ rule_type?(rule['type'], value) &&
39
+ (!rule.key?('enum') || rule['enum'].include?(value)) &&
40
+ (!rule.key?('minimum') || value >= rule['minimum']) &&
41
+ (!rule.key?('maximum') || value <= rule['maximum'])
42
+ end
43
+
44
+ def rule_type?(type, value)
45
+ case type
46
+ when 'object' then value.is_a?(Hash)
47
+ when 'string' then value.is_a?(String)
48
+ when 'boolean' then [true, false].include?(value)
49
+ when 'number' then value.is_a?(Integer) || (value.is_a?(Float) && value.finite?)
50
+ when 'integer' then value.is_a?(Integer) || (value.is_a?(Float) && value.finite? && value == value.floor)
51
+ else true
52
+ end
53
+ end
54
+
55
+ NUMBER = /\A-?(?:0|[1-9]\d*)(?:\.\d+)?\z/
56
+ LENS = /\A[a-z0-9-]{1,50}\z/
57
+
58
+ # Reads a CDN selector, the path text after the resource as sent (still percent-encoded), as the
59
+ # CDN does: an optional first `@lens`, then `key=value` options with dotted keys for nested ones (a
60
+ # bare key is `true`). Values are numbers, `true`, `false`, hex colours (`#` added) for colour
61
+ # options, or text. Raises ArgumentError on anything the CDN refuses as `asset/invalid-transform`.
62
+ # Used by the mock CDN.
63
+ # @return [Array(String, Hash)] the lens and the options, each nil when absent
64
+ def parse_selector(selector)
65
+ return [nil, nil] if selector.empty?
66
+ raise ArgumentError, "Invalid transform selector #{selector.inspect}" if selector.include?('/')
67
+
68
+ entries = selector.split(',', -1)
69
+ lens = nil
70
+ if entries.first.start_with?('@')
71
+ lens = selector_text(entries.shift.delete_prefix('@'), selector)
72
+ raise ArgumentError, "Invalid transform selector #{selector.inspect}" unless lens.match?(LENS)
73
+ end
74
+ return [lens, nil] if entries.empty?
75
+
76
+ options = {}
77
+ entries.each { |entry| add_selector_entry(options, entry, selector) }
78
+ [lens, options]
79
+ end
80
+
81
+ def add_selector_entry(options, entry, selector)
82
+ fail = -> { ArgumentError.new("Invalid transform selector #{selector.inspect}") }
83
+ raise fail.call if entry.empty? || entry.start_with?('@')
84
+
85
+ key, separator, text = entry.partition('=')
86
+ parts = key.split('.', -1)
87
+ raise fail.call unless parts.all? { |part| part.match?(Transforms::OPTION_NAME) }
88
+
89
+ value = separator.empty? || selector_value(key, selector_text(text, selector))
90
+ raise fail.call if value == ''
91
+
92
+ target = parts[0..-2].reduce(options) do |current, part|
93
+ raise fail.call unless current[part].nil? || current[part].is_a?(Hash)
94
+
95
+ current[part] ||= {}
96
+ end
97
+ raise fail.call if target.key?(parts.last)
98
+
99
+ target[parts.last] = value
100
+ end
101
+
102
+ def selector_value(key, text)
103
+ return text if text.empty?
104
+ return "##{text.downcase}" if Transforms::COLOUR_KEYS.include?(key) && Transforms::HEX_COLOUR.match?("##{text}")
105
+ return text.include?('.') ? Float(text) : Integer(text, 10) if text.match?(NUMBER)
106
+ return text == 'true' if %w[true false].include?(text)
107
+
108
+ text
109
+ end
110
+
111
+ # Percent-decodes selector text, refusing malformed escapes and text that isn't UTF-8.
112
+ def selector_text(text, selector)
113
+ decoded = text.b.gsub(/%(\h\h)/n) { Regexp.last_match(1).hex.chr }.force_encoding(Encoding::UTF_8)
114
+ raise ArgumentError, "Invalid transform selector #{selector.inspect}" if text.match?(/%(?!\h\h)/) || !decoded.valid_encoding?
115
+
116
+ decoded
117
+ end
118
+ end
119
+ private_constant :TransformRules
120
+ end
@@ -0,0 +1,236 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Generated by packages/sdk-contract/generate.ts from IMAGE_TRANSFORM_OPTIONS_SCHEMA; do not edit.
4
+ # The rules each transform option is checked against; see packages/sdk-contract/README.md.
5
+
6
+ require 'json'
7
+
8
+ module CalmLens
9
+ module Internal
10
+ # The image transform options' schema, with only the rules the SDK checks.
11
+ TRANSFORM_OPTIONS_SCHEMA = JSON.parse(<<~JSON).freeze
12
+ {
13
+ "type": "object",
14
+ "properties": {
15
+ "format": {
16
+ "type": "string",
17
+ "enum": [
18
+ "jpg",
19
+ "png",
20
+ "webp",
21
+ "avif",
22
+ "gif"
23
+ ]
24
+ },
25
+ "width": {
26
+ "type": "integer",
27
+ "minimum": 1,
28
+ "maximum": 10000
29
+ },
30
+ "height": {
31
+ "type": "integer",
32
+ "minimum": 1,
33
+ "maximum": 10000
34
+ },
35
+ "dpr": {
36
+ "type": "number",
37
+ "minimum": 1,
38
+ "maximum": 3
39
+ },
40
+ "anim": {
41
+ "type": "boolean"
42
+ },
43
+ "background": {
44
+ "type": "string"
45
+ },
46
+ "blur": {
47
+ "type": "number"
48
+ },
49
+ "redact": {
50
+ "type": "object",
51
+ "properties": {
52
+ "text": {
53
+ "type": "boolean"
54
+ },
55
+ "personalInformation": {
56
+ "type": "boolean"
57
+ },
58
+ "faces": {
59
+ "type": "boolean"
60
+ }
61
+ }
62
+ },
63
+ "border": {
64
+ "type": "object",
65
+ "properties": {
66
+ "color": {
67
+ "type": "string"
68
+ },
69
+ "width": {
70
+ "type": "number"
71
+ }
72
+ }
73
+ },
74
+ "brightness": {
75
+ "type": "number"
76
+ },
77
+ "contrast": {
78
+ "type": "number"
79
+ },
80
+ "quality": {
81
+ "oneOf": [
82
+ {
83
+ "type": "integer",
84
+ "minimum": 1,
85
+ "maximum": 100
86
+ },
87
+ {
88
+ "type": "string",
89
+ "enum": [
90
+ "low",
91
+ "medium",
92
+ "high"
93
+ ]
94
+ }
95
+ ]
96
+ },
97
+ "fit": {
98
+ "type": "string",
99
+ "enum": [
100
+ "scale-down",
101
+ "contain",
102
+ "pad",
103
+ "squeeze",
104
+ "cover",
105
+ "crop"
106
+ ]
107
+ },
108
+ "flip": {
109
+ "type": "string",
110
+ "enum": [
111
+ "h",
112
+ "v",
113
+ "hv"
114
+ ]
115
+ },
116
+ "gamma": {
117
+ "type": "number"
118
+ },
119
+ "segment": {
120
+ "type": "string",
121
+ "enum": [
122
+ "foreground"
123
+ ]
124
+ },
125
+ "gravity": {
126
+ "oneOf": [
127
+ {
128
+ "type": "string",
129
+ "enum": [
130
+ "face",
131
+ "left",
132
+ "right",
133
+ "top",
134
+ "bottom",
135
+ "center",
136
+ "auto",
137
+ "entropy"
138
+ ]
139
+ },
140
+ {
141
+ "type": "object",
142
+ "properties": {
143
+ "mode": {
144
+ "type": "string",
145
+ "enum": [
146
+ "remainder",
147
+ "box-center"
148
+ ]
149
+ },
150
+ "x": {
151
+ "type": "number"
152
+ },
153
+ "y": {
154
+ "type": "number"
155
+ }
156
+ },
157
+ "required": [
158
+ "mode"
159
+ ]
160
+ }
161
+ ]
162
+ },
163
+ "rotate": {
164
+ "type": "integer",
165
+ "enum": [
166
+ 0,
167
+ 90,
168
+ 180,
169
+ 270
170
+ ]
171
+ },
172
+ "saturation": {
173
+ "type": "number"
174
+ },
175
+ "sharpen": {
176
+ "type": "number"
177
+ },
178
+ "trim": {
179
+ "oneOf": [
180
+ {
181
+ "type": "string",
182
+ "enum": [
183
+ "border"
184
+ ]
185
+ },
186
+ {
187
+ "type": "object",
188
+ "properties": {
189
+ "top": {
190
+ "type": "number"
191
+ },
192
+ "bottom": {
193
+ "type": "number"
194
+ },
195
+ "left": {
196
+ "type": "number"
197
+ },
198
+ "right": {
199
+ "type": "number"
200
+ },
201
+ "width": {
202
+ "type": "number"
203
+ },
204
+ "height": {
205
+ "type": "number"
206
+ },
207
+ "border": {
208
+ "oneOf": [
209
+ {
210
+ "type": "boolean"
211
+ },
212
+ {
213
+ "type": "object",
214
+ "properties": {
215
+ "color": {
216
+ "type": "string"
217
+ },
218
+ "tolerance": {
219
+ "type": "number"
220
+ },
221
+ "keep": {
222
+ "type": "number"
223
+ }
224
+ }
225
+ }
226
+ ]
227
+ }
228
+ }
229
+ }
230
+ ]
231
+ }
232
+ }
233
+ }
234
+ JSON
235
+ end
236
+ end