calmlens 0.22.0 → 0.23.1
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/lib/calmlens/assets.rb +19 -11
- data/lib/calmlens/cdn.rb +2 -2
- data/lib/calmlens/lenses.rb +1 -1
- data/lib/calmlens/server_client.rb +1 -1
- data/lib/calmlens/session.rb +2 -3
- data/lib/calmlens/testing/classification.rb +1 -1
- data/lib/calmlens/testing/fake_api.rb +93 -21
- data/lib/calmlens/testing/mock_decision.rb +45 -5
- data/lib/calmlens/testing/test_inputs.rb +4 -3
- data/lib/calmlens/testing.rb +10 -6
- data/lib/calmlens/transform_schema.rb +0 -14
- data/lib/calmlens/transport.rb +3 -0
- data/lib/calmlens/version.rb +1 -1
- data/sig/calmlens.rbs +4 -1
- data/sig/qualifiers.rbs +1 -0
- data/sig/types.rbs +9 -18
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0707eda417af2a9021104b813fecc82ca3a4cddbcc420d92fcd22642f844d61f
|
|
4
|
+
data.tar.gz: c834ec6d48c5815e93d1af413efbd2c8cab0b4fb67071d2da17ac9d97ffab018
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cfa54ba17a0a73abea37f1b34a76df43a2172ffa93e6801f0ae1cd671b40970516675fdcca7014fed69a8acf686a542f78e101fc7000580cc1c11f31c07ac095
|
|
7
|
+
data.tar.gz: 71a628e67b7887807d5c241913b4125836bf5493b6bb54558d9321bb47a4331ea54079dd94d4161dcb02dcc79c798d5653bd891f9c1b3ff3d8848f7cebac9bbd
|
data/lib/calmlens/assets.rb
CHANGED
|
@@ -310,7 +310,8 @@ module CalmLens
|
|
|
310
310
|
end
|
|
311
311
|
|
|
312
312
|
# Uploads content and waits for the moderation decision where the content type supports it.
|
|
313
|
-
#
|
|
313
|
+
# Documents, websites, audio, video and files over 20 MiB sent in parts always finish in the
|
|
314
|
+
# background and return `pending`. Takes the options of {#create}.
|
|
314
315
|
# @return [Hash] the asset; show it to everyone only when `classification.moderation.status` is
|
|
315
316
|
# `allowed` or `limited` (reduce the reach of `limited` assets). Serve `age-restricted` assets
|
|
316
317
|
# only behind your own age check, through signed URLs.
|
|
@@ -358,24 +359,31 @@ module CalmLens
|
|
|
358
359
|
def poll_for_decision(asset_id, request_options, timeout)
|
|
359
360
|
deadline = @session.monotonic + timeout
|
|
360
361
|
delay = DECISION_POLL_FIRST_DELAY
|
|
362
|
+
processing = moderation = nil
|
|
361
363
|
loop do
|
|
362
|
-
|
|
364
|
+
remaining = deadline - @session.monotonic
|
|
365
|
+
break if timeout.positive? && !remaining.positive?
|
|
366
|
+
|
|
367
|
+
# The outer deadline covers transport retries and the entire response read.
|
|
368
|
+
asset = ::Timeout.timeout(timeout.zero? ? nil : remaining) do
|
|
369
|
+
retrieve('assetId' => asset_id, 'request_options' => request_options)
|
|
370
|
+
end
|
|
371
|
+
remaining = deadline - @session.monotonic
|
|
372
|
+
break if timeout.positive? && !remaining.positive?
|
|
373
|
+
|
|
363
374
|
processing = asset.dig('classification', 'status')
|
|
364
375
|
moderation = asset.dig('classification', 'moderation', 'status')
|
|
365
376
|
return asset if SETTLED_STATUSES.include?(processing)
|
|
366
|
-
|
|
367
|
-
remaining = deadline - @session.monotonic
|
|
368
|
-
unless remaining.positive?
|
|
369
|
-
raise TimeoutError.new(SdkErrorCodes::TIMEOUT,
|
|
370
|
-
"Asset #{asset['id']} was not settled after #{timeout}s " \
|
|
371
|
-
"(processing #{processing}, moderation #{moderation})",
|
|
372
|
-
details: { 'assetId' => asset['id'], 'classificationStatus' => processing,
|
|
373
|
-
'moderationStatus' => moderation })
|
|
374
|
-
end
|
|
377
|
+
break unless remaining.positive?
|
|
375
378
|
|
|
376
379
|
sleep([delay, remaining].min)
|
|
377
380
|
delay = [delay * DECISION_POLL_FACTOR, DECISION_POLL_MAX_DELAY].min
|
|
378
381
|
end
|
|
382
|
+
raise ::Timeout::Error
|
|
383
|
+
rescue ::Timeout::Error
|
|
384
|
+
raise TimeoutError.new(SdkErrorCodes::TIMEOUT, "Asset #{asset_id} was not settled after #{timeout}s",
|
|
385
|
+
details: { 'assetId' => asset_id, 'classificationStatus' => processing,
|
|
386
|
+
'moderationStatus' => moderation })
|
|
379
387
|
end
|
|
380
388
|
|
|
381
389
|
def submit(options, request_options, wait)
|
data/lib/calmlens/cdn.rb
CHANGED
|
@@ -53,7 +53,7 @@ module CalmLens
|
|
|
53
53
|
# @option options [String] :resource `rendered` (the displayable file: an image in the best format the
|
|
54
54
|
# client accepts, a PDF, or browser-safe video or audio), `original` (the exact upload), `preview` (a
|
|
55
55
|
# still image for the asset), `hls` (the HLS master playlist of audio or video, at `hls/index.m3u8`,
|
|
56
|
-
# once `content.hls` is
|
|
56
|
+
# once `content.hls.status` is `ready`), `embed` (an HTML page that shows the asset, a viewer for its type, for an
|
|
57
57
|
# iframe), `placeholder` (a small blurred PNG that the CDN serves publicly for every asset whatever its
|
|
58
58
|
# visibility or moderation status, without age confirmation) or `info` (the asset's display info, as
|
|
59
59
|
# {#get_asset_info} reads it). Without it, the URL is the asset URL itself. Lenses and transforms
|
|
@@ -69,7 +69,7 @@ module CalmLens
|
|
|
69
69
|
# alpha-2 country. The CDN serves an asset whose moderation status is `age-restricted` only with
|
|
70
70
|
# `age_confirmed: true`, even with a signed URL or the API key. Set it only where your app has
|
|
71
71
|
# already decided the viewer may see age-restricted content. Your platform confirms the viewer's
|
|
72
|
-
# age;
|
|
72
|
+
# age; viewer claims are assertions from your app, and CalmLens doesn't verify them.
|
|
73
73
|
# @return [String]
|
|
74
74
|
def get_asset_url(options)
|
|
75
75
|
call(options, __method__) { |api, _request| asset_url(api) }
|
data/lib/calmlens/lenses.rb
CHANGED
|
@@ -35,7 +35,7 @@ module CalmLens
|
|
|
35
35
|
# @option options [String] :identifier
|
|
36
36
|
# @option options [String] :name
|
|
37
37
|
# @option options [String] :type `image`
|
|
38
|
-
# @option options [Boolean] :active whether the CDN serves it
|
|
38
|
+
# @option options [Boolean] :active whether the CDN serves it; true by default
|
|
39
39
|
# @option options [String, nil] :description
|
|
40
40
|
# @option options [Hash, String, nil] :transform
|
|
41
41
|
# @return [Hash] the lens
|
|
@@ -134,7 +134,7 @@ module CalmLens
|
|
|
134
134
|
# them. Definitions this client already saved are not sent again, and sending the same
|
|
135
135
|
# definition again returns the lens unchanged. More than 100 new definitions are sent in
|
|
136
136
|
# consecutive requests, in order. {#lenses} manages lenses one by one.
|
|
137
|
-
# @option options [Array<Hash>] :lenses `[{identifier:, name:, type:,
|
|
137
|
+
# @option options [Array<Hash>] :lenses `[{identifier:, name:, type:, transform:, ...}]`, each served unless `active: false`
|
|
138
138
|
# @return [Array<Hash>] the saved lenses, in the given order.
|
|
139
139
|
def ensure_lenses(options) = @lenses.ensure_lenses(options)
|
|
140
140
|
|
data/lib/calmlens/session.rb
CHANGED
|
@@ -180,11 +180,10 @@ module CalmLens
|
|
|
180
180
|
private
|
|
181
181
|
|
|
182
182
|
# The fields that identify a lens definition, as canonical JSON: an unset description or
|
|
183
|
-
# transform is the same definition as an explicit null
|
|
183
|
+
# transform is the same definition as an explicit null, and an unset `active` as `true`.
|
|
184
184
|
def lens_key(lens)
|
|
185
185
|
fields = { 'identifier' => lens['identifier'], 'name' => lens['name'], 'description' => lens['description'],
|
|
186
|
-
'type' => lens['type'], 'transform' => lens['transform'] }
|
|
187
|
-
fields['active'] = lens['active'] if lens.key?('active')
|
|
186
|
+
'type' => lens['type'], 'transform' => lens['transform'], 'active' => lens.fetch('active', true) }
|
|
188
187
|
JSON.generate(canonical(fields))
|
|
189
188
|
end
|
|
190
189
|
|
|
@@ -8,7 +8,7 @@ module CalmLens
|
|
|
8
8
|
|
|
9
9
|
# Each category's qualifiers.
|
|
10
10
|
QUALIFIERS = {
|
|
11
|
-
"sexual" => ["sexual-pornographic-media", "sexual-nudity", "sexual-activity", "sexual-explicit-language", "sexual-assault-media", "sexual-non-consensual", "sexual-intimate-image-abuse", "sexual-extreme", "sexual-strangulation", "sexual-incest", "sexual-minors", "sexual-solicitation", "sexual-paid", "sexual-adult-promotion", "sexual-revealing-clothing", "sexual-suggestive", "sexual-non-erotic-nudity", "sexual-text", "sexual-historical", "sexual-documentary", "sexual-educational", "sexual-discussion", "sexual-fictional", "sexual-support-prevention"].freeze,
|
|
11
|
+
"sexual" => ["sexual-pornographic-media", "sexual-nudity", "sexual-activity", "sexual-explicit-language", "sexual-assault-media", "sexual-non-consensual", "sexual-intimate-image-abuse", "sexual-extreme", "sexual-strangulation", "sexual-incest", "sexual-minors", "sexual-solicitation", "sexual-paid", "sexual-adult-promotion", "sexual-revealing-clothing", "sexual-suggestive", "sexual-non-erotic-nudity", "sexual-text", "sexual-historical", "sexual-documentary", "sexual-educational", "sexual-discussion", "sexual-fictional", "sexual-artistic", "sexual-support-prevention"].freeze,
|
|
12
12
|
"violence" => ["violence-high-impact", "violence-attack", "violence-threat", "violence-preparation", "violence-encouragement", "violence-wish", "violence-coded-incitement", "violence-extremism", "violence-injury", "violence-violent-death", "violence-natural-death", "violence-graphic", "violence-decapitation-act", "violence-gore", "violence-weapon", "violence-sport", "violence-text", "violence-historical", "violence-documentary", "violence-educational", "violence-discussion", "violence-fictional", "violence-fantasy", "violence-support-prevention"].freeze,
|
|
13
13
|
"self-harm" => ["self-harm-age-restricted", "self-harm-thoughts", "self-harm-preparation", "self-harm-act", "self-harm-methods", "self-harm-encouragement", "self-harm-historical", "self-harm-documentary", "self-harm-educational", "self-harm-fictional", "self-harm-support-prevention"].freeze,
|
|
14
14
|
"harassment" => ["harassment-abuse", "harassment-mild", "harassment-bullying", "harassment-public-target", "harassment-threat", "harassment-sexual", "harassment-encouragement", "harassment-historical", "harassment-documentary", "harassment-educational", "harassment-fictional", "harassment-support-prevention"].freeze,
|
|
@@ -81,7 +81,7 @@ module CalmLens
|
|
|
81
81
|
# from its pixels. The `placeholder` resource serves it as a PNG, like the CDN.
|
|
82
82
|
THUMBHASH = '1QcSHQRnh493V4dIh4eXh1h4kJUI'
|
|
83
83
|
# Removing or withholding content removes its rendered file, adaptive stream and preview, as the API does.
|
|
84
|
-
NO_RENDERING = { 'rendered' => nil, 'hls' => nil, '
|
|
84
|
+
NO_RENDERING = { 'rendered' => nil, 'hls' => nil, 'preview' => nil }.freeze
|
|
85
85
|
# The size of the waveform the API makes as an audio asset's preview.
|
|
86
86
|
WAVEFORM_SIZE = { 'width' => 1200, 'height' => 300 }.freeze
|
|
87
87
|
# Audio and video formats browsers play as uploaded, so the API serves them as stored (Asset.ts).
|
|
@@ -272,6 +272,9 @@ module CalmLens
|
|
|
272
272
|
# Like the API: a queued re-run no longer replaces a reviewed decision.
|
|
273
273
|
@reruns.delete(item['id'])
|
|
274
274
|
apply_outcome(item, decision, 'requested')
|
|
275
|
+
# Like the API: the human review queue's decision settles its review, one through the API
|
|
276
|
+
# leaves it waiting.
|
|
277
|
+
item['classification']['moderation']['pendingReview'] = nil unless by_api_key
|
|
275
278
|
review = { 'id' => @create_id.call('rev'), 'decision' => decision, 'note' => note,
|
|
276
279
|
'decidedBy' => { 'type' => 'api-key', 'id' => nil }, 'reviewedAt' => @now.call, 'applied' => true }
|
|
277
280
|
actor = by_api_key ? API_KEY_ACTOR : REVIEWER_ACTOR
|
|
@@ -613,6 +616,7 @@ module CalmLens
|
|
|
613
616
|
old = @state.lenses[definition['identifier']]
|
|
614
617
|
return new_lens(definition) unless old
|
|
615
618
|
|
|
619
|
+
definition = active_by_default(definition)
|
|
616
620
|
same = %w[name type active].all? { |key| old[key] == definition[key] } &&
|
|
617
621
|
old['description'] == definition['description'] && canonical(old['transform']) == canonical(definition['transform'])
|
|
618
622
|
return old if same
|
|
@@ -622,10 +626,13 @@ module CalmLens
|
|
|
622
626
|
|
|
623
627
|
def new_lens(definition)
|
|
624
628
|
now = @now.call
|
|
625
|
-
lens = { 'id' => @create_id.call('lens'), 'projectId' => @project_id }.merge(definition)
|
|
629
|
+
lens = { 'id' => @create_id.call('lens'), 'projectId' => @project_id }.merge(active_by_default(definition))
|
|
626
630
|
@state.lenses[definition['identifier']] = lens.merge('createdAt' => now, 'updatedAt' => now)
|
|
627
631
|
end
|
|
628
632
|
|
|
633
|
+
# Like the API, a lens is served unless its definition says otherwise.
|
|
634
|
+
def active_by_default(definition) = definition['active'].nil? ? definition.merge('active' => true) : definition
|
|
635
|
+
|
|
629
636
|
# POST /projects/:id/lenses: like the API, the identifier must be new to the project.
|
|
630
637
|
def create_lens(json)
|
|
631
638
|
identifier = json['identifier']
|
|
@@ -923,23 +930,70 @@ module CalmLens
|
|
|
923
930
|
|
|
924
931
|
options, bytes = form_fields(body, request[:content_type])
|
|
925
932
|
fields = options.merge('contentType' => query['contentType'])
|
|
926
|
-
return create_asset(fields, queue, data: bytes) if upload == 'content'
|
|
933
|
+
return create_asset(apply_upload_preset(fields, bytes), queue, data: bytes) if upload == 'content'
|
|
934
|
+
|
|
935
|
+
start_multipart(apply_upload_preset(fields.merge(query.slice('isFinal', 'totalSizeInBytes')), bytes), bytes, queue)
|
|
936
|
+
end
|
|
937
|
+
|
|
938
|
+
# An API-key upload through a preset, as the API applies it: the preset gives the folder and
|
|
939
|
+
# visibility, which the upload can't change, and the processing options unless the upload sets
|
|
940
|
+
# its own, and refuses types and sizes it doesn't accept.
|
|
941
|
+
def apply_upload_preset(fields, data = nil)
|
|
942
|
+
return fields unless fields['uploadPresetId']
|
|
943
|
+
|
|
944
|
+
preset = @upload_presets.values.find { |item| item['id'] == fields['uploadPresetId'] }
|
|
945
|
+
fail!(ErrorCodes::UPLOAD_PRESET_NOT_FOUND) unless preset
|
|
946
|
+
fail!(ErrorCodes::UPLOAD_PRESET_DISABLED) unless preset['enabled']
|
|
947
|
+
check_preset_fields(preset, fields)
|
|
948
|
+
check_preset_limits(preset, fields, data)
|
|
949
|
+
fields.merge('folderId' => preset['folderId'] || fields['folderId'], 'visibility' => preset['visibility'],
|
|
950
|
+
'processingOptions' => fields['processingOptions'] || deep_copy(preset['processingOptions']))
|
|
951
|
+
end
|
|
952
|
+
|
|
953
|
+
def check_preset_fields(preset, fields)
|
|
954
|
+
if preset['folderId'] && fields['folderId'] && fields['folderId'] != preset['folderId']
|
|
955
|
+
fail!(ErrorCodes::UPLOAD_PRESET_FIELD_NOT_ALLOWED, details: { 'field' => 'folderId' })
|
|
956
|
+
end
|
|
957
|
+
return unless fields['visibility'] && fields['visibility'] != preset['visibility']
|
|
958
|
+
|
|
959
|
+
fail!(ErrorCodes::UPLOAD_PRESET_FIELD_NOT_ALLOWED, details: { 'field' => 'visibility' })
|
|
960
|
+
end
|
|
961
|
+
|
|
962
|
+
def check_preset_limits(preset, fields, data)
|
|
963
|
+
allowed = preset['allowedContentTypes']
|
|
964
|
+
type = fields['contentType']
|
|
965
|
+
if allowed && type && allowed.none? { |pattern| preset_accepts?(pattern, fields['name'], type) }
|
|
966
|
+
fail!(ErrorCodes::UNSUPPORTED_UPLOAD_TYPE,
|
|
967
|
+
details: { 'source' => 'preset', 'contentType' => type, 'allowedContentTypes' => allowed })
|
|
968
|
+
end
|
|
969
|
+
limit = preset['maxSizeInBytes']
|
|
970
|
+
size = fields['totalSizeInBytes'] || data&.bytesize
|
|
971
|
+
fail!(ErrorCodes::UPLOAD_TOO_LARGE, details: { 'limitBytes' => limit, 'source' => 'preset' }) if limit && size && size > limit
|
|
972
|
+
end
|
|
927
973
|
|
|
928
|
-
|
|
974
|
+
# Whether an upload matches one entry of a preset's list, such as `image/*`, `text/plain` or `.pdf`.
|
|
975
|
+
def preset_accepts?(pattern, name, type)
|
|
976
|
+
pattern = pattern.strip.downcase
|
|
977
|
+
return true if ['*', '*/*'].include?(pattern)
|
|
978
|
+
return type.downcase.start_with?(pattern.delete_suffix('*')) if pattern.end_with?('/*')
|
|
979
|
+
return name.to_s.downcase.end_with?(pattern) if pattern.start_with?('.')
|
|
980
|
+
|
|
981
|
+
type.downcase == pattern
|
|
929
982
|
end
|
|
930
983
|
|
|
931
984
|
# A JSON create: text as `content` or a first `part` (`{ plainText, type }`), or a URL.
|
|
932
985
|
def create_from_json(json, queue)
|
|
933
986
|
key = %w[content part].find { |name| json.key?(name) }
|
|
934
|
-
return create_asset(json, queue, url: json['url']) unless key
|
|
987
|
+
return create_asset(apply_upload_preset(json), queue, url: json['url']) unless key
|
|
935
988
|
|
|
936
989
|
text = json[key]
|
|
937
990
|
invalid!('validation/invalid-type', key) unless text.is_a?(Hash) && text['plainText'].is_a?(String) && text['type'].is_a?(String)
|
|
938
991
|
fields = json.except('content', 'part', 'sizeInBytes', 'totalSizeInBytes').merge('contentType' => text['type'])
|
|
939
992
|
data = text['plainText'].encode(Encoding::UTF_8).b
|
|
940
|
-
return create_asset(fields, queue, data: data) if key == 'content'
|
|
993
|
+
return create_asset(apply_upload_preset(fields, data), queue, data: data) if key == 'content'
|
|
941
994
|
|
|
942
|
-
|
|
995
|
+
part = fields.merge('isFinal' => text['isFinal'] == true, 'totalSizeInBytes' => json['totalSizeInBytes'])
|
|
996
|
+
start_multipart(apply_upload_preset(part, data), data, queue)
|
|
943
997
|
end
|
|
944
998
|
|
|
945
999
|
# The asset fields in an upload form's options part, and the file's bytes. Like the API, a raw
|
|
@@ -1023,7 +1077,7 @@ module CalmLens
|
|
|
1023
1077
|
# faces in an image, so a decided image reports that the features its upload ran found nothing:
|
|
1024
1078
|
# OCR and person and face detection when turned on, link detection unless turned off.
|
|
1025
1079
|
def decided(asset, decision)
|
|
1026
|
-
apply_outcome(asset, decision.status, 'policy', decision.report)
|
|
1080
|
+
apply_outcome(asset, decision.status, 'policy', decision.report(@now.call))
|
|
1027
1081
|
classification = asset['classification']
|
|
1028
1082
|
return asset unless classification['status'] == 'completed' && asset['type'] == 'image'
|
|
1029
1083
|
|
|
@@ -1136,7 +1190,7 @@ module CalmLens
|
|
|
1136
1190
|
# play the upload), audio with a waveform preview and video with no poster; and every preview has
|
|
1137
1191
|
# its ThumbHash. Text, spreadsheets and other files have neither. The mock decodes nothing, so
|
|
1138
1192
|
# sizes are unknown, audio has no peaks, and audio and video never get an adaptive stream.
|
|
1139
|
-
def rendering(asset, processed) = { **rendered_file(asset, processed), 'hls' => nil
|
|
1193
|
+
def rendering(asset, processed) = { **rendered_file(asset, processed), 'hls' => nil }
|
|
1140
1194
|
|
|
1141
1195
|
# The rendered file and preview of {#rendering}.
|
|
1142
1196
|
def rendered_file(asset, processed)
|
|
@@ -1728,8 +1782,8 @@ module CalmLens
|
|
|
1728
1782
|
# Answers like the CDN (serveAssetResource.ts): `original` is the upload; `rendered` the image,
|
|
1729
1783
|
# PDF, video or audio file `content.rendered` describes; `preview` the still `content.preview`
|
|
1730
1784
|
# describes; `hls` the adaptive stream's files, which the mock never prepares; `embed` a small
|
|
1731
|
-
# page standing in for the embed page of any asset; and `placeholder` the preview's ThumbHash as a PNG
|
|
1732
|
-
#
|
|
1785
|
+
# page standing in for the embed page of any asset; and `placeholder` the preview's ThumbHash as a PNG.
|
|
1786
|
+
# A safety hold refuses every resource. Other resources pass delivery checks first,
|
|
1733
1787
|
# which refuse everyone (content still uploading, blocked or purged, failed processing, removed
|
|
1734
1788
|
# content), then an age-restricted asset needs `viewerClaims.ageConfirmed=true` whatever the key,
|
|
1735
1789
|
# and a private asset needs an API key with `assets:download` for its folder. The mock converts
|
|
@@ -1746,20 +1800,32 @@ module CalmLens
|
|
|
1746
1800
|
grant, item = resolve_asset(raw_segments[2])
|
|
1747
1801
|
return grant if grant.is_a?(Response)
|
|
1748
1802
|
return asset_not_found unless item && RESOURCES.include?(resource)
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1803
|
+
|
|
1804
|
+
refused = refuse_viewer(item, resource, grant, headers)
|
|
1805
|
+
return refused if refused
|
|
1752
1806
|
|
|
1753
1807
|
params = URI.decode_www_form(query.to_s).to_h
|
|
1754
1808
|
# A grant authorises a private asset without the API key.
|
|
1755
1809
|
authorised = !grant.nil? || can_download?(item['folderId'], headers)
|
|
1756
1810
|
return info(method, item, authorised, params) if resource == 'info'
|
|
1757
1811
|
return not_available if item['content']['safetyHold']
|
|
1758
|
-
return placeholder(method, item) if resource == 'placeholder'
|
|
1812
|
+
return placeholder(method, item, authorised) if resource == 'placeholder'
|
|
1759
1813
|
|
|
1760
1814
|
deliver(method, item, resource, params, selected ? options || {} : nil, authorised)
|
|
1761
1815
|
end
|
|
1762
1816
|
|
|
1817
|
+
# What a grant doesn't cover, or, like the CDN, a private asset for anyone without a grant or the
|
|
1818
|
+
# project's API key: it answers as an unknown asset does, whatever its state, except for its
|
|
1819
|
+
# info, which has its own rule. An error response, or nil.
|
|
1820
|
+
def refuse_viewer(item, resource, grant, headers)
|
|
1821
|
+
if resource == 'original' && grant && !grant[:original]
|
|
1822
|
+
return error(ErrorCodes::SIGNED_URL_ORIGINAL_NOT_GRANTED, "This signed asset URL doesn't grant the original file", status: 403)
|
|
1823
|
+
end
|
|
1824
|
+
return if grant || item['visibility'] == 'public' || header(headers, 'X-Api-Key') == @api_key
|
|
1825
|
+
|
|
1826
|
+
asset_not_found unless resource == 'info'
|
|
1827
|
+
end
|
|
1828
|
+
|
|
1763
1829
|
# Whether the path after the resource selects a lens or transform, and its options; nil when the
|
|
1764
1830
|
# CDN refuses it. Like the CDN, the adaptive stream's files sit beneath `/hls/` (its master
|
|
1765
1831
|
# playlist is `/hls/index.m3u8`). Otherwise the selector after the resource is read
|
|
@@ -1800,12 +1866,15 @@ module CalmLens
|
|
|
1800
1866
|
restricted = item['visibility'] != 'public'
|
|
1801
1867
|
return asset_not_found if restricted && !authorised
|
|
1802
1868
|
|
|
1869
|
+
restricted ||= %w[blocked purged].include?(item['classification']['moderation']['status'])
|
|
1870
|
+
return not_available if restricted && !authorised
|
|
1871
|
+
|
|
1803
1872
|
claims = JSON.parse(params.fetch('viewerClaims', '{}'))
|
|
1804
1873
|
body = { 'id' => item['id'], 'projectId' => item['projectId'], 'type' => item['type'], 'externalUrl' => item['externalUrl'],
|
|
1805
1874
|
'content' => item['content'].slice(*%w[format width height durationSeconds pageCount rendered preview hls]),
|
|
1806
1875
|
'availability' => availability(item, claims.is_a?(Hash) && claims['ageConfirmed'] == true) }
|
|
1807
1876
|
cdn_response(method, JSON.generate(body), { 'content-type' => 'application/json',
|
|
1808
|
-
'cache-control' =>
|
|
1877
|
+
'cache-control' => restricted ? 'private, no-store' : 'public, no-cache',
|
|
1809
1878
|
'cache-tag' => "asset-#{item['id']}" })
|
|
1810
1879
|
rescue JSON::ParserError
|
|
1811
1880
|
error(ErrorCodes::INVALID_VIEWER_CLAIMS, 'Invalid viewer claims', status: 400)
|
|
@@ -1914,15 +1983,18 @@ module CalmLens
|
|
|
1914
1983
|
|
|
1915
1984
|
def not_available = error('asset/not-available', 'Asset is not available', status: 403)
|
|
1916
1985
|
|
|
1917
|
-
#
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1986
|
+
# A preview requires access for private or blocked assets and disappears with its content.
|
|
1987
|
+
def placeholder(method, item, authorised)
|
|
1988
|
+
private_asset = item['visibility'] != 'public'
|
|
1989
|
+
blocked = item['classification']['moderation']['status'] == 'blocked'
|
|
1990
|
+
return asset_not_found if private_asset && !authorised
|
|
1991
|
+
return not_available if blocked && !authorised
|
|
1992
|
+
|
|
1921
1993
|
thumbhash = item.dig('content', 'preview', 'thumbhash')
|
|
1922
1994
|
return not_available if thumbhash.nil? || item['classification']['moderation']['status'] == 'purged'
|
|
1923
1995
|
|
|
1924
1996
|
cdn_response(method, Placeholder.png(thumbhash),
|
|
1925
|
-
'content-type' => 'image/png', 'cache-control' => 'public,
|
|
1997
|
+
'content-type' => 'image/png', 'cache-control' => private_asset || blocked ? 'private, no-store' : 'public, no-cache',
|
|
1926
1998
|
'cache-tag' => "asset-#{item['id']}", 'etag' => "\"#{thumbhash}\"")
|
|
1927
1999
|
end
|
|
1928
2000
|
|
|
@@ -5,11 +5,13 @@ require_relative 'classification'
|
|
|
5
5
|
module CalmLens
|
|
6
6
|
# A moderation decision for {MockServerClient}'s `moderate:` option or
|
|
7
7
|
# {MockServerClient#complete_processing}, started with {.of}: flag the categories it found, with
|
|
8
|
-
# their scores and qualifiers,
|
|
9
|
-
# names and scores and raises `sdk/invalid-argument` for one
|
|
8
|
+
# their scores and qualifiers, mark the categories it couldn't assess, and leave it waiting for
|
|
9
|
+
# human review. Each call checks its names and scores and raises `sdk/invalid-argument` for one
|
|
10
|
+
# CalmLens doesn't have.
|
|
10
11
|
#
|
|
11
12
|
# @example
|
|
12
13
|
# CalmLens::MockDecision.of('blocked').flag('violence', 0.9, 'violence-threat' => 0.9).not_evaluated('profanity')
|
|
14
|
+
# CalmLens::MockDecision.of('limited').flag('violence', 0.5).pending_review('uncertain', ['violence'])
|
|
13
15
|
class MockDecision
|
|
14
16
|
# Where the mock's moderation leaves an upload: `pending` (still processing), `failed`
|
|
15
17
|
# (processing failed, retryable), or a decision with that moderation status (`purged` also
|
|
@@ -19,6 +21,9 @@ module CalmLens
|
|
|
19
21
|
STATUS_ACTIONS = { 'allowed' => 'allow', 'limited' => 'limit', 'age-restricted' => 'age-restrict', 'blocked' => 'block',
|
|
20
22
|
'purged' => 'purge' }.freeze
|
|
21
23
|
private_constant :STATUS_ACTIONS
|
|
24
|
+
# Why a decision waits for human review.
|
|
25
|
+
PENDING_REVIEW_REASONS = %w[sampled uncertain].freeze
|
|
26
|
+
private_constant :PENDING_REVIEW_REASONS
|
|
22
27
|
|
|
23
28
|
# @return [String]
|
|
24
29
|
attr_reader :status
|
|
@@ -81,18 +86,40 @@ module CalmLens
|
|
|
81
86
|
self
|
|
82
87
|
end
|
|
83
88
|
|
|
89
|
+
# Leaves the decision waiting for human review, as `classification.moderation.pendingReview`
|
|
90
|
+
# reports: `sampled`, or `uncertain` with the `uncertain_categories` that made it so.
|
|
91
|
+
# {MockServerClient#decide} clears it; {Assets#decide} doesn't, as with the API.
|
|
92
|
+
# @param reason [String] `sampled` or `uncertain`
|
|
93
|
+
# @param uncertain_categories [Array<String>]
|
|
94
|
+
# @return [self]
|
|
95
|
+
# @raise [InvalidArgumentError] for a `pending` or `failed` decision, another reason, a `sampled`
|
|
96
|
+
# reason with uncertain categories, or an unknown category
|
|
97
|
+
def pending_review(reason, uncertain_categories = [])
|
|
98
|
+
raise Internal.invalid("a #{@status} decision can't wait for review") if undecided?
|
|
99
|
+
|
|
100
|
+
reason = reason.to_s
|
|
101
|
+
raise Internal.invalid("reason must be one of #{PENDING_REVIEW_REASONS.join(', ')}") unless PENDING_REVIEW_REASONS.include?(reason)
|
|
102
|
+
raise Internal.invalid('uncertain_categories must be an Array') unless uncertain_categories.is_a?(Array)
|
|
103
|
+
raise Internal.invalid('a sampled review has no uncertain categories') if reason == 'sampled' && !uncertain_categories.empty?
|
|
104
|
+
|
|
105
|
+
@pending_review = { 'reason' => reason, 'uncertainCategories' => uncertain_categories.map { |category| known_category(category) } }
|
|
106
|
+
self
|
|
107
|
+
end
|
|
108
|
+
|
|
84
109
|
# The report a decided asset gets, as the API sends it: every category (unflagged ones
|
|
85
110
|
# evaluated with score 0), the policy's actions, the automated status and `flaggedCategories`.
|
|
111
|
+
# @param requested_at [Integer] when a pending review joined the queue, in epoch milliseconds
|
|
86
112
|
# @return [Hash{String => Object}, nil] `categories`, `score` and `policy`; nil for `pending` and `failed`
|
|
87
|
-
def report
|
|
113
|
+
def report(requested_at = 0)
|
|
88
114
|
return if undecided?
|
|
89
115
|
|
|
90
116
|
evaluated = (MockClassification::CATEGORIES - @not_evaluated).map { |category| [category, @flags.dig(category, :score) || 0] }
|
|
91
117
|
{ 'raw' => { 'categories' => MockClassification::CATEGORIES.to_h { |category| [category, category_report(category)] },
|
|
92
118
|
'hashMatches' => [] },
|
|
93
|
-
'categories' => evaluated.to_h { |category, value| [category,
|
|
119
|
+
'categories' => evaluated.to_h { |category, value| [category, policy_result(value)] },
|
|
94
120
|
# The mock keeps no policies, so no policy is named.
|
|
95
121
|
'explanationComplete' => true, 'policy' => nil, 'source' => 'policy', 'rule' => nil, 'automatedStatus' => @status,
|
|
122
|
+
'pendingReview' => @pending_review && { 'requestedAt' => requested_at, **@pending_review },
|
|
96
123
|
'regions' => [], 'linkSafety' => nil, 'flaggedCategories' => evaluated.reject { |_, value| action(value) == 'allow' }.map(&:first) }
|
|
97
124
|
end
|
|
98
125
|
|
|
@@ -102,9 +129,15 @@ module CalmLens
|
|
|
102
129
|
|
|
103
130
|
def undecided? = %w[pending failed].include?(@status)
|
|
104
131
|
|
|
105
|
-
def
|
|
132
|
+
def known_category(category)
|
|
106
133
|
category = category.to_s
|
|
107
134
|
raise Internal.invalid("#{category} is not a CalmLens category") unless MockClassification::CATEGORIES.include?(category)
|
|
135
|
+
|
|
136
|
+
category
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def check_category(category)
|
|
140
|
+
category = known_category(category)
|
|
108
141
|
raise Internal.invalid("a #{@status} decision has no findings") if undecided?
|
|
109
142
|
raise Internal.invalid("#{category} is already in this decision") if @flags.key?(category) || @not_evaluated.include?(category)
|
|
110
143
|
|
|
@@ -115,6 +148,13 @@ module CalmLens
|
|
|
115
148
|
|
|
116
149
|
def action(score) = @status != 'allowed' && score >= 0.5 ? STATUS_ACTIONS[@status] : 'allow'
|
|
117
150
|
|
|
151
|
+
# A category's policy result. The mock keeps no policies: the category's setting is the
|
|
152
|
+
# decision's own action.
|
|
153
|
+
def policy_result(score)
|
|
154
|
+
{ 'score' => score, 'action' => action(score), 'source' => { 'kind' => score >= 0.5 ? 'default' : 'below-threshold' },
|
|
155
|
+
'settings' => { 'action' => STATUS_ACTIONS[@status], 'allowances' => [], 'restrictions' => [] } }
|
|
156
|
+
end
|
|
157
|
+
|
|
118
158
|
# The API's likelihood for a score (ClassificationCategories.ts, scoreToLikelihood).
|
|
119
159
|
def likelihood(score)
|
|
120
160
|
if score >= 0.8 then 'very-likely'
|
|
@@ -56,7 +56,7 @@ module CalmLens
|
|
|
56
56
|
person = { 'box' => [2500, 1500, 5000, 8500] }
|
|
57
57
|
face = ->(age = nil) { { 'box' => [3500, 2000, 3000, 3500], **(age ? { 'age' => age } : {}) } }
|
|
58
58
|
# Ready-made scenarios (deeply frozen): a clean input, a limited one, adult images and text,
|
|
59
|
-
# regional delivery,
|
|
59
|
+
# regional delivery, delivery blocked in Germany, mixed instances in one category, and test
|
|
60
60
|
# images with one person and face, nobody, an adult's face, a minor's face and a group of three.
|
|
61
61
|
PRESETS = deep_freeze.call(
|
|
62
62
|
clean: { 'instances' => [] },
|
|
@@ -66,7 +66,7 @@ module CalmLens
|
|
|
66
66
|
regional_delivery: { 'instances' => [], 'regionalActions' => [{ 'region' => 'DE', 'action' => 'block' },
|
|
67
67
|
{ 'region' => 'GB', 'action' => 'age-restrict' },
|
|
68
68
|
{ 'region' => 'FR', 'action' => 'limit' }] },
|
|
69
|
-
germany:
|
|
69
|
+
germany: { 'instances' => [], 'regionalActions' => [{ 'region' => 'DE', 'action' => 'block' }] },
|
|
70
70
|
mixed: { 'instances' => [{ 'category' => 'harassment', 'qualifiers' => %w[harassment-abuse harassment-documentary] },
|
|
71
71
|
{ 'category' => 'harassment', 'qualifiers' => ['harassment-threat'] }] },
|
|
72
72
|
one_face: { 'instances' => [], 'detections' => { 'people' => [person], 'faces' => [face.call] } },
|
|
@@ -97,7 +97,8 @@ module CalmLens
|
|
|
97
97
|
preset = case signal
|
|
98
98
|
when 'harassment.insult' then PRESETS[:limited]
|
|
99
99
|
when 'sexual.adult' then PRESETS[kind == 'image' ? :adult_image : :adult_text]
|
|
100
|
-
when 'hate.regional-symbol'
|
|
100
|
+
when 'hate.regional-symbol'
|
|
101
|
+
{ 'instances' => [{ 'category' => 'hate', 'qualifiers' => %w[hate-symbol hate-nazi-symbols-public-display] }] }
|
|
101
102
|
when 'sexual.involving-minors' then { 'instances' => [{ 'category' => 'sexual', 'qualifiers' => ['sexual-minors'] }] }
|
|
102
103
|
when 'violence.graphic'
|
|
103
104
|
{ 'instances' => [{ 'category' => 'violence', 'qualifiers' => %w[violence-injury violence-graphic] }] }
|
data/lib/calmlens/testing.rb
CHANGED
|
@@ -27,8 +27,10 @@ module CalmLens
|
|
|
27
27
|
# grant (the default key has no grants), or the URL is signed: {CDN#sign_asset_url} returns
|
|
28
28
|
# `{cdn_base_url}/assets/{project_id}/sig_…`, a readable grant that only the mock's CDN accepts,
|
|
29
29
|
# which serves `original` only when signed with `original: true`. {#fetch_cdn} loads such a URL
|
|
30
|
-
# without the API key, as a browser would
|
|
31
|
-
#
|
|
30
|
+
# without the API key, as a browser would; without the key or a grant, a private asset answers
|
|
31
|
+
# as an unknown one does, `asset/not-found` (404) whatever its state, and only its placeholder is
|
|
32
|
+
# public. {#decide} applies a reviewer's decision, and {Assets#wait_for_decision} takes no real
|
|
33
|
+
# time: a pending asset times out at once.
|
|
32
34
|
# Filters join comparisons with `;` (and), which binds tighter than `,` (or), and group them with
|
|
33
35
|
# `( … )`; list cursors resume after the last item returned. {Assets#search} is the API's keyword
|
|
34
36
|
# search: every query word (case-insensitive, up to 8) must appear in a searchable field, and
|
|
@@ -78,7 +80,7 @@ module CalmLens
|
|
|
78
80
|
REQUEST_FAILURES = %w[transport-error timeout].freeze
|
|
79
81
|
# The methods {#fail_next_request} can target, by resource. Iterators are their list calls.
|
|
80
82
|
OPERATIONS = { assets: %i[search list retrieve list_children list_timeline update create classify upload_part move
|
|
81
|
-
retry explain wait_for_decision remove_content delete batch],
|
|
83
|
+
retry explain decide wait_for_decision remove_content delete batch],
|
|
82
84
|
folders: %i[list retrieve create update delete move],
|
|
83
85
|
hash_lists: %i[list retrieve create update delete list_entries add_entry remove_entry],
|
|
84
86
|
policies: %i[list retrieve retrieve_assigned create update reset upgrade set_automatic_updates delete
|
|
@@ -200,8 +202,9 @@ module CalmLens
|
|
|
200
202
|
|
|
201
203
|
# Applies a reviewer's decision to a stored asset, as the portal or API would: the moderation
|
|
202
204
|
# status changes (`purged` also removes the content), the review joins `manualReviews` with
|
|
203
|
-
# `decidedBy` the mock API key (`{ 'type' => 'api-key', 'id' => nil }`) and `applied: true`,
|
|
204
|
-
# `asset.<decision>` event whose `actor` is a `user` joins the events feed
|
|
205
|
+
# `decidedBy` the mock API key (`{ 'type' => 'api-key', 'id' => nil }`) and `applied: true`, an
|
|
206
|
+
# `asset.<decision>` event whose `actor` is a `user` joins the events feed, and a waiting
|
|
207
|
+
# `pendingReview` is settled ({Assets#decide} leaves it waiting, as with the API).
|
|
205
208
|
# @param asset_id [String]
|
|
206
209
|
# @param decision [String] `allowed`, `limited`, `age-restricted`, `blocked` or `purged`
|
|
207
210
|
# @return [Hash] the asset
|
|
@@ -271,7 +274,8 @@ module CalmLens
|
|
|
271
274
|
# Fetches a URL of the mock's CDN as a browser would, with a GET and without the API key: a signed
|
|
272
275
|
# asset URL from {CDN#sign_asset_url} (with a resource appended), or a public one from
|
|
273
276
|
# {CDN#get_asset_url}. The request is recorded like any other. Error statuses are returned, not
|
|
274
|
-
# raised.
|
|
277
|
+
# raised. A private asset without a grant answers `asset/not-found` (404) whatever its state,
|
|
278
|
+
# except for its public placeholder.
|
|
275
279
|
# @param url [String]
|
|
276
280
|
# @return [Response] with its body read
|
|
277
281
|
def fetch_cdn(url)
|
|
@@ -46,20 +46,6 @@ module CalmLens
|
|
|
46
46
|
"blur": {
|
|
47
47
|
"type": "number"
|
|
48
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
49
|
"border": {
|
|
64
50
|
"type": "object",
|
|
65
51
|
"properties": {
|
data/lib/calmlens/transport.rb
CHANGED
|
@@ -19,7 +19,10 @@ module CalmLens
|
|
|
19
19
|
# @!attribute open_timeout [Numeric] seconds allowed to open a connection.
|
|
20
20
|
# @!attribute timeout [Numeric] seconds allowed for each read or write; not a whole-transfer deadline.
|
|
21
21
|
Request = Struct.new(:method, :url, :headers, :body, :open_timeout, :timeout, keyword_init: true) do
|
|
22
|
+
# Struct's own `to_s` and `pretty_print` would show the headers, which hold the API key.
|
|
22
23
|
def inspect = "#<#{self.class.name} #{method} #{url}>"
|
|
24
|
+
def to_s = inspect
|
|
25
|
+
def pretty_print(printer) = printer.text(inspect)
|
|
23
26
|
end
|
|
24
27
|
|
|
25
28
|
# Exceptions that mean the connection failed before a complete response arrived.
|
data/lib/calmlens/version.rb
CHANGED
data/sig/calmlens.rbs
CHANGED
|
@@ -470,8 +470,11 @@ module CalmLens
|
|
|
470
470
|
# Flags `category` with a score from 0 to 1 and optional qualifier scores.
|
|
471
471
|
def flag: (String | Symbol category, Numeric score, ?Hash[String | Symbol, Numeric] qualifiers) -> self
|
|
472
472
|
def not_evaluated: (String | Symbol category) -> self
|
|
473
|
+
# Leaves the decision waiting for human review: `sampled`, or `uncertain` with the categories that made it so.
|
|
474
|
+
def pending_review: ("sampled" | "uncertain" | :sampled | :uncertain reason, ?Array[String | Symbol] uncertain_categories) -> self
|
|
473
475
|
# The `categories`, `score` and `policy` a decided asset gets; nil for `pending` and `failed`.
|
|
474
|
-
|
|
476
|
+
# `requested_at` is when a pending review joined the queue, in epoch milliseconds.
|
|
477
|
+
def report: (?Integer requested_at) -> Hash[String, json_value]?
|
|
475
478
|
end
|
|
476
479
|
|
|
477
480
|
# What MockServerClient's `moderate:` callback receives.
|
data/sig/qualifiers.rbs
CHANGED
|
@@ -57,6 +57,7 @@ module CalmLens
|
|
|
57
57
|
?"sexual-educational" => qualifier_result,
|
|
58
58
|
?"sexual-discussion" => qualifier_result,
|
|
59
59
|
?"sexual-fictional" => qualifier_result,
|
|
60
|
+
?"sexual-artistic" => qualifier_result,
|
|
60
61
|
?"sexual-support-prevention" => qualifier_result,
|
|
61
62
|
?"violence-attack" => qualifier_result,
|
|
62
63
|
?"violence-threat" => qualifier_result,
|
data/sig/types.rbs
CHANGED
|
@@ -102,8 +102,11 @@ module CalmLens
|
|
|
102
102
|
# Preview. The adaptive stream of audio or video: HLS with fragmented MP4 (CMAF) segments, served
|
|
103
103
|
# at the CDN's `hls` resource (`hls/index.m3u8`) and played by its `embed` resource. Renditions list
|
|
104
104
|
# video from the largest down, then audio.
|
|
105
|
-
type asset_hls = {
|
|
106
|
-
|
|
105
|
+
type asset_hls = {
|
|
106
|
+
"status" => String,
|
|
107
|
+
"reason" => String?,
|
|
108
|
+
"renditions" => Array[asset_hls_rendition]
|
|
109
|
+
}
|
|
107
110
|
type asset_rendered_kind = "image" | "pdf" | "video" | "audio"
|
|
108
111
|
# The asset's one displayable file, as the CDN's `rendered` resource serves it: an image, a PDF, or
|
|
109
112
|
# browser-safe video or audio. `peaks` is an audio waveform's normalised peaks.
|
|
@@ -262,7 +265,6 @@ module CalmLens
|
|
|
262
265
|
# Preview. The adaptive stream once streaming has prepared one for audio or video; `nil` for
|
|
263
266
|
# other assets, until then, once the content is removed and while the asset is held.
|
|
264
267
|
"hls" => asset_hls?,
|
|
265
|
-
"hlsStatus" => asset_hls_status?,
|
|
266
268
|
"preview" => asset_preview?
|
|
267
269
|
}
|
|
268
270
|
|
|
@@ -434,8 +436,7 @@ module CalmLens
|
|
|
434
436
|
}
|
|
435
437
|
# Where OCR found text in an image, and whether it is personal information.
|
|
436
438
|
type text_region = { "box" => bounding_box, "personalInformation" => bool }
|
|
437
|
-
# `status: "evaluated"` carries `textRegions`, where OCR found text
|
|
438
|
-
# these regions); `"not-evaluated"` carries `reason`. The text itself is `content.extractedText`.
|
|
439
|
+
# `status: "evaluated"` carries `textRegions`, where OCR found text; `"not-evaluated"` carries `reason`. The text itself is `content.extractedText`.
|
|
439
440
|
type ocr_result = {
|
|
440
441
|
"status" => "evaluated" | "not-evaluated",
|
|
441
442
|
?"textRegions" => Array[text_region],
|
|
@@ -543,11 +544,6 @@ module CalmLens
|
|
|
543
544
|
?anim: bool,
|
|
544
545
|
?background: String,
|
|
545
546
|
?blur: number,
|
|
546
|
-
?redact: {
|
|
547
|
-
?text: bool,
|
|
548
|
-
?personalInformation: bool,
|
|
549
|
-
?faces: bool
|
|
550
|
-
},
|
|
551
547
|
?border: {
|
|
552
548
|
?color: String,
|
|
553
549
|
?width: number
|
|
@@ -590,11 +586,6 @@ module CalmLens
|
|
|
590
586
|
?"anim" => bool,
|
|
591
587
|
?"background" => String,
|
|
592
588
|
?"blur" => number,
|
|
593
|
-
?"redact" => {
|
|
594
|
-
?"text" => bool,
|
|
595
|
-
?"personalInformation" => bool,
|
|
596
|
-
?"faces" => bool
|
|
597
|
-
},
|
|
598
589
|
?"border" => {
|
|
599
590
|
?"color" => String,
|
|
600
591
|
?"width" => number
|
|
@@ -635,12 +626,12 @@ module CalmLens
|
|
|
635
626
|
type transform_input = String | image_transform_options_input
|
|
636
627
|
|
|
637
628
|
type lens_type = "image" | "video" | "audio" | "document" | "website"
|
|
638
|
-
# ENSURE_LENS_BODY_SCHEMA: a lens definition, which the CDN serves once it is saved.
|
|
629
|
+
# ENSURE_LENS_BODY_SCHEMA: a lens definition, which the CDN serves once it is saved unless `active` is false.
|
|
639
630
|
type lens_definition = {
|
|
640
631
|
identifier: String,
|
|
641
632
|
name: String,
|
|
642
633
|
type: lens_type,
|
|
643
|
-
active: bool,
|
|
634
|
+
?active: bool,
|
|
644
635
|
?description: String?,
|
|
645
636
|
?transform: image_transform_options_input?
|
|
646
637
|
}
|
|
@@ -1168,7 +1159,7 @@ module CalmLens
|
|
|
1168
1159
|
identifier: String,
|
|
1169
1160
|
name: String,
|
|
1170
1161
|
type: lens_type,
|
|
1171
|
-
active: bool,
|
|
1162
|
+
?active: bool,
|
|
1172
1163
|
?description: String?,
|
|
1173
1164
|
?transform: image_transform_options_input?,
|
|
1174
1165
|
?request_options: request_options
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: calmlens
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.23.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- CalmLens
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-11-
|
|
11
|
+
date: 2026-11-10 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description: 'Server-side client for the CalmLens API: moderation, uploads, events,
|
|
14
14
|
webhooks and CDN delivery.'
|