calmlens 0.23.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f31d8d0e9739bebd69a0bab938d60914fb7173c8543b5521bcd5ac97e441311a
4
- data.tar.gz: c85ad7397dd68637309b17204ff12dc7d52ec1e6415d66c42bacb7a000b9e9cf
3
+ metadata.gz: 977f88cea877f294fefbc6259eef7e99c7327ba3e90333540da37c86ad74b599
4
+ data.tar.gz: 1ac90ad7c9fa1a7c11bf1abce6595efffb58b0cbdbd2a93035f1f1bc8f0c5ab9
5
5
  SHA512:
6
- metadata.gz: 7fb2274df7c0dfa834c97e786ab3b37816ec447c5d7694a293866a75882f11e4a052e684c3c1559a477a7e7419a02144a2af232291bf21f868bffa9fada5eed8
7
- data.tar.gz: '090730bd39a5d48f41a7d7cfdbda56538166304f1af6cf6b22c9046997135f15a66384054a9e02751d168696b46c5a811b01abf825fc77f8a1f04b808da1c8d1'
6
+ metadata.gz: 6654f2b874569fd13005016adc0a7efbec542db6bf378fa04d5ebfe4f3bb43c7cd85b04c504ac6110966d826f9a92c1e1d9a82aa796e19ed01b3f00920cc1179
7
+ data.tar.gz: 26e933bc44b4577d13be2e01907c9578cd84ebe3ec3dd2e51d969da5b6f990254466a3728dccb0ecd6e1a2c74699769ed5d5bfd0f8ab90a36ce8224bcf171e9b
@@ -359,24 +359,31 @@ module CalmLens
359
359
  def poll_for_decision(asset_id, request_options, timeout)
360
360
  deadline = @session.monotonic + timeout
361
361
  delay = DECISION_POLL_FIRST_DELAY
362
+ processing = moderation = nil
362
363
  loop do
363
- asset = retrieve('assetId' => asset_id, 'request_options' => request_options)
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
+
364
374
  processing = asset.dig('classification', 'status')
365
375
  moderation = asset.dig('classification', 'moderation', 'status')
366
376
  return asset if SETTLED_STATUSES.include?(processing)
367
-
368
- remaining = deadline - @session.monotonic
369
- unless remaining.positive?
370
- raise TimeoutError.new(SdkErrorCodes::TIMEOUT,
371
- "Asset #{asset['id']} was not settled after #{timeout}s " \
372
- "(processing #{processing}, moderation #{moderation})",
373
- details: { 'assetId' => asset['id'], 'classificationStatus' => processing,
374
- 'moderationStatus' => moderation })
375
- end
377
+ break unless remaining.positive?
376
378
 
377
379
  sleep([delay, remaining].min)
378
380
  delay = [delay * DECISION_POLL_FACTOR, DECISION_POLL_MAX_DELAY].min
379
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 })
380
387
  end
381
388
 
382
389
  def submit(options, request_options, wait)
data/lib/calmlens/cdn.rb CHANGED
@@ -54,8 +54,8 @@ module CalmLens
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
56
  # once `content.hls.status` is `ready`), `embed` (an HTML page that shows the asset, a viewer for its type, for an
57
- # iframe), `placeholder` (a small blurred PNG that the CDN serves publicly for every asset whatever its
58
- # visibility or moderation status, without age confirmation) or `info` (the asset's display info, as
57
+ # iframe), `placeholder` (a small blurred PNG, without age confirmation; private or blocked assets
58
+ # require a signed URL or download permission) 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
60
60
  # apply to `preview`, and to `rendered` when it is an image; the CDN refuses them on rendered PDFs,
61
61
  # video and audio with `transform/invalid`, and the SDK refuses them on the other resources and
@@ -14,7 +14,7 @@ module CalmLens
14
14
  IDEMPOTENCY_KEY = /\A[^\r\n\0]+\z/
15
15
  HEADER_VALUE = /\A[^\r\n\0]*\z/
16
16
  # The CDN resources that take no lens or transform: the exact upload, the adaptive stream, the embed page,
17
- # the public placeholder and the display info.
17
+ # the placeholder and the display info.
18
18
  UNSELECTABLE_CDN_RESOURCES = %w[original hls embed placeholder info].freeze
19
19
 
20
20
  module_function
@@ -730,7 +730,8 @@ module CalmLens
730
730
  end
731
731
 
732
732
  def show_project_settings
733
- settings = { 'optOutJurisdictions' => [], 'blockUnmoderatedAssets' => true, 'storeSensitiveMetadata' => false,
733
+ settings = { 'retainContent' => true, 'optOutJurisdictions' => [], 'blockUnmoderatedAssets' => true,
734
+ 'storeSensitiveMetadata' => false,
734
735
  'streamingEnabled' => false, 'aiMediaSearchEnabled' => false, 'remoteUrlRule' => nil,
735
736
  'websiteSubmissionsPerMinute' => nil }.merge(@project_settings)
736
737
  settings['humanReview'] = { 'allowedSamplePercent' => 0, 'restrictedSamplePercent' => 0, 'uncertaintyMarginPercent' => 0,
@@ -842,8 +843,9 @@ module CalmLens
842
843
  end
843
844
  now = @now.call
844
845
  mime = options['contentType'].to_s
845
- processing = options['processingOptions'] || { 'keepContentAfterProcessing' => false }
846
- keep = processing.is_a?(Hash) && processing['keepContentAfterProcessing'] == true
846
+ processing = (options['processingOptions'] || {}).dup
847
+ processing['retainContent'] = show_project_settings['settings']['retainContent'] if processing['retainContent'].nil?
848
+ keep = processing.is_a?(Hash) && processing['retainContent'] == true
847
849
  context = options['context'].is_a?(Hash) ? ModerationContext.select_items(options['context'].fetch('items', [])) : []
848
850
  # Fields the API returns as null when they are not set.
849
851
  asset = { 'id' => @create_id.call('asset'), 'projectId' => @project_id, 'parentId' => nil, 'parentPages' => [],
@@ -946,8 +948,12 @@ module CalmLens
946
948
  fail!(ErrorCodes::UPLOAD_PRESET_DISABLED) unless preset['enabled']
947
949
  check_preset_fields(preset, fields)
948
950
  check_preset_limits(preset, fields, data)
951
+ retain = fields.dig('processingOptions', 'retainContent')
952
+ retain = preset.dig('processingOptions', 'retainContent') if retain.nil?
949
953
  fields.merge('folderId' => preset['folderId'] || fields['folderId'], 'visibility' => preset['visibility'],
950
- 'processingOptions' => fields['processingOptions'] || deep_copy(preset['processingOptions']))
954
+ 'processingOptions' => (fields['processingOptions'] || deep_copy(preset['processingOptions']) || {}).merge(
955
+ 'retainContent' => retain
956
+ ))
951
957
  end
952
958
 
953
959
  def check_preset_fields(preset, fields)
@@ -1782,8 +1788,8 @@ module CalmLens
1782
1788
  # Answers like the CDN (serveAssetResource.ts): `original` is the upload; `rendered` the image,
1783
1789
  # PDF, video or audio file `content.rendered` describes; `preview` the still `content.preview`
1784
1790
  # describes; `hls` the adaptive stream's files, which the mock never prepares; `embed` a small
1785
- # page standing in for the embed page of any asset; and `placeholder` the preview's ThumbHash as a PNG, public
1786
- # whatever the asset's visibility or moderation. A safety hold refuses every resource. Other resources pass delivery checks first,
1791
+ # page standing in for the embed page of any asset; and `placeholder` the preview's ThumbHash as a PNG.
1792
+ # A safety hold refuses every resource. Other resources pass delivery checks first,
1787
1793
  # which refuse everyone (content still uploading, blocked or purged, failed processing, removed
1788
1794
  # content), then an age-restricted asset needs `viewerClaims.ageConfirmed=true` whatever the key,
1789
1795
  # and a private asset needs an API key with `assets:download` for its folder. The mock converts
@@ -1809,21 +1815,21 @@ module CalmLens
1809
1815
  authorised = !grant.nil? || can_download?(item['folderId'], headers)
1810
1816
  return info(method, item, authorised, params) if resource == 'info'
1811
1817
  return not_available if item['content']['safetyHold']
1812
- return placeholder(method, item) if resource == 'placeholder'
1818
+ return placeholder(method, item, authorised) if resource == 'placeholder'
1813
1819
 
1814
1820
  deliver(method, item, resource, params, selected ? options || {} : nil, authorised)
1815
1821
  end
1816
1822
 
1817
1823
  # What a grant doesn't cover, or, like the CDN, a private asset for anyone without a grant or the
1818
1824
  # project's API key: it answers as an unknown asset does, whatever its state, except for its
1819
- # public placeholder and its info, which has its own rule. An error response, or nil.
1825
+ # info, which has its own rule. An error response, or nil.
1820
1826
  def refuse_viewer(item, resource, grant, headers)
1821
1827
  if resource == 'original' && grant && !grant[:original]
1822
1828
  return error(ErrorCodes::SIGNED_URL_ORIGINAL_NOT_GRANTED, "This signed asset URL doesn't grant the original file", status: 403)
1823
1829
  end
1824
1830
  return if grant || item['visibility'] == 'public' || header(headers, 'X-Api-Key') == @api_key
1825
1831
 
1826
- asset_not_found unless %w[placeholder info].include?(resource)
1832
+ asset_not_found unless resource == 'info'
1827
1833
  end
1828
1834
 
1829
1835
  # Whether the path after the resource selects a lens or transform, and its options; nil when the
@@ -1866,12 +1872,15 @@ module CalmLens
1866
1872
  restricted = item['visibility'] != 'public'
1867
1873
  return asset_not_found if restricted && !authorised
1868
1874
 
1875
+ restricted ||= %w[blocked purged].include?(item['classification']['moderation']['status'])
1876
+ return not_available if restricted && !authorised
1877
+
1869
1878
  claims = JSON.parse(params.fetch('viewerClaims', '{}'))
1870
1879
  body = { 'id' => item['id'], 'projectId' => item['projectId'], 'type' => item['type'], 'externalUrl' => item['externalUrl'],
1871
1880
  'content' => item['content'].slice(*%w[format width height durationSeconds pageCount rendered preview hls]),
1872
1881
  'availability' => availability(item, claims.is_a?(Hash) && claims['ageConfirmed'] == true) }
1873
1882
  cdn_response(method, JSON.generate(body), { 'content-type' => 'application/json',
1874
- 'cache-control' => "#{restricted ? 'private' : 'public'}, max-age=60",
1883
+ 'cache-control' => restricted ? 'private, no-store' : 'public, no-cache',
1875
1884
  'cache-tag' => "asset-#{item['id']}" })
1876
1885
  rescue JSON::ParserError
1877
1886
  error(ErrorCodes::INVALID_VIEWER_CLAIMS, 'Invalid viewer claims', status: 400)
@@ -1980,15 +1989,18 @@ module CalmLens
1980
1989
 
1981
1990
  def not_available = error('asset/not-available', 'Asset is not available', status: 403)
1982
1991
 
1983
- # Like the CDN's placeholder handler: the preview's ThumbHash, public whatever the asset's
1984
- # visibility or moderation; refused for purged assets and assets without one (not yet
1985
- # processed, without a preview, or content removed).
1986
- def placeholder(method, item)
1992
+ # A preview requires access for private or blocked assets and disappears with its content.
1993
+ def placeholder(method, item, authorised)
1994
+ private_asset = item['visibility'] != 'public'
1995
+ blocked = item['classification']['moderation']['status'] == 'blocked'
1996
+ return asset_not_found if private_asset && !authorised
1997
+ return not_available if blocked && !authorised
1998
+
1987
1999
  thumbhash = item.dig('content', 'preview', 'thumbhash')
1988
2000
  return not_available if thumbhash.nil? || item['classification']['moderation']['status'] == 'purged'
1989
2001
 
1990
2002
  cdn_response(method, Placeholder.png(thumbhash),
1991
- 'content-type' => 'image/png', 'cache-control' => 'public, max-age=3600',
2003
+ 'content-type' => 'image/png', 'cache-control' => private_asset || blocked ? 'private, no-store' : 'public, no-cache',
1992
2004
  'cache-tag' => "asset-#{item['id']}", 'etag' => "\"#{thumbhash}\"")
1993
2005
  end
1994
2006
 
@@ -17,7 +17,7 @@ module CalmLens
17
17
  # callback decides each upload, retry and rerun with a status or a {MockDecision}, and the events
18
18
  # feed gets the pipeline's `asset.uploaded`, `asset.classified`, `asset.<status>` and `asset.failed`
19
19
  # events. Without it, queued uploads stay `pending` until {#complete_processing} and the rest are
20
- # `allowed`. Content is kept only with `keepContentAfterProcessing: true`; otherwise it is removed
20
+ # `allowed`. Content retention follows upload, preset and project settings; when disabled, content is removed
21
21
  # once classification is `completed`, as in the API.
22
22
  # A `purged` outcome removes the content with reason `policy`. The CDN answers like the real one:
23
23
  # `asset/not-available` (403) for blocked, purged, failed or removed content,
@@ -28,8 +28,8 @@ module CalmLens
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
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
31
+ # as an unknown one does, `asset/not-found` (404) whatever its state, including its placeholder
32
+ # and display info. {#decide} applies a reviewer's decision, and {Assets#wait_for_decision} takes no real
33
33
  # time: a pending asset times out at once.
34
34
  # Filters join comparisons with `;` (and), which binds tighter than `,` (or), and group them with
35
35
  # `( … )`; list cursors resume after the last item returned. {Assets#search} is the API's keyword
@@ -275,7 +275,7 @@ module CalmLens
275
275
  # asset URL from {CDN#sign_asset_url} (with a resource appended), or a public one from
276
276
  # {CDN#get_asset_url}. The request is recorded like any other. Error statuses are returned, not
277
277
  # raised. A private asset without a grant answers `asset/not-found` (404) whatever its state,
278
- # except for its public placeholder.
278
+ # including its placeholder and display info.
279
279
  # @param url [String]
280
280
  # @return [Response] with its body read
281
281
  def fetch_cdn(url)
@@ -2,5 +2,5 @@
2
2
 
3
3
  module CalmLens
4
4
  # The gem version. The gemspec and the User-Agent header read it from here.
5
- VERSION = '0.23.0'
5
+ VERSION = '0.24.0'
6
6
  end
data/sig/types.rbs CHANGED
@@ -70,12 +70,12 @@ module CalmLens
70
70
  ?face_detection: { enabled: bool, ?age_estimation: bool }?,
71
71
  ?link_detection: { enabled: bool }?,
72
72
  # Preview. Prepares an adaptive stream (HLS) of audio or video, served at the `hls` resource and
73
- # played by the `embed` resource; needs `keep_content_after_processing`. Left out, the project's
73
+ # played by the `embed` resource; needs `retain_content`. Left out, the project's
74
74
  # streaming setting decides.
75
75
  ?streaming: { enabled: bool }?
76
76
  }
77
77
  type processing_options_input = {
78
- ?keep_content_after_processing: bool?,
78
+ ?retain_content: bool?,
79
79
  # Overrides the project's `storeSensitiveMetadata` setting: false removes embedded EXIF, XMP and
80
80
  # IPTC metadata before the image is stored; true stores it as uploaded.
81
81
  ?store_sensitive_metadata: bool?,
@@ -658,7 +658,7 @@ module CalmLens
658
658
  type upload_preset_access = "token" | "anyone"
659
659
  # An upload preset's processing options, as the API returns them.
660
660
  type upload_preset_processing_options = {
661
- ?"keepContentAfterProcessing" => bool?,
661
+ ?"retainContent" => bool?,
662
662
  ?"storeSensitiveMetadata" => bool?,
663
663
  ?"features" => {
664
664
  ?"ocr" => { "enabled" => bool }?,
@@ -703,6 +703,7 @@ module CalmLens
703
703
  }
704
704
  type public_project_settings = {
705
705
  "optOutJurisdictions" => Array[String],
706
+ "retainContent" => bool,
706
707
  "blockUnmoderatedAssets" => bool,
707
708
  "storeSensitiveMetadata" => bool,
708
709
  "streamingEnabled" => bool,
@@ -1204,6 +1205,7 @@ module CalmLens
1204
1205
  # The complete settings: those left out return to their defaults.
1205
1206
  type project_settings_input = {
1206
1207
  ?opt_out_jurisdictions: Array[String],
1208
+ ?retain_content: bool,
1207
1209
  ?block_unmoderated_assets: bool,
1208
1210
  ?store_sensitive_metadata: bool,
1209
1211
  ?streaming_enabled: bool,
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.23.0
4
+ version: 0.24.0
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-09 00:00:00.000000000 Z
11
+ date: 2026-11-11 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.'
@@ -69,7 +69,7 @@ metadata:
69
69
  homepage_uri: https://calmlens.com
70
70
  documentation_uri: https://calmlens.com/docs/sdk/ruby
71
71
  source_code_uri: https://github.com/CalmLens/sdks/tree/main/ruby
72
- changelog_uri: https://calmlens.com/docs/changelog
72
+ changelog_uri: https://github.com/CalmLens/sdks/blob/main/ruby/CHANGELOG.md
73
73
  rubygems_mfa_required: 'true'
74
74
  post_install_message:
75
75
  rdoc_options: []