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.
- checksums.yaml +4 -4
- data/README.md +3 -1
- data/lib/calmlens/assets.rb +142 -42
- data/lib/calmlens/cdn.rb +14 -7
- data/lib/calmlens/error_codes.rb +28 -2
- data/lib/calmlens/events.rb +5 -2
- data/lib/calmlens/folders.rb +9 -6
- data/lib/calmlens/hash_lists.rb +11 -8
- data/lib/calmlens/internal.rb +10 -0
- data/lib/calmlens/lenses.rb +6 -2
- data/lib/calmlens/option_keys.rb +89 -0
- data/lib/calmlens/policies.rb +100 -0
- data/lib/calmlens/resource.rb +32 -1
- data/lib/calmlens/server_client.rb +23 -6
- data/lib/calmlens/session.rb +6 -3
- data/lib/calmlens/testing/classification.rb +3 -0
- data/lib/calmlens/testing/fake_api.rb +206 -20
- data/lib/calmlens/testing/test_image_grid.rb +161 -0
- data/lib/calmlens/testing/test_input_scenario.rb +127 -0
- data/lib/calmlens/testing/test_inputs.rb +157 -0
- data/lib/calmlens/testing.rb +10 -2
- data/lib/calmlens/text.rb +5 -4
- data/lib/calmlens/transform_rules.rb +120 -0
- data/lib/calmlens/transform_schema.rb +236 -0
- data/lib/calmlens/transforms.rb +37 -28
- data/lib/calmlens/upload_body.rb +14 -0
- data/lib/calmlens/upload_mime_types.rb +43 -0
- data/lib/calmlens/upload_tokens.rb +4 -1
- data/lib/calmlens/version.rb +1 -1
- data/lib/calmlens.rb +5 -0
- data/sig/calmlens.rbs +59 -3
- data/sig/error_codes.rbs +14 -1
- data/sig/types.rbs +104 -20
- metadata +10 -2
|
@@ -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
|
data/lib/calmlens/testing.rb
CHANGED
|
@@ -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,
|
|
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
|