parse-stack-next 5.5.5 → 5.6.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,211 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ module Parse
5
+ module Embeddings
6
+ # Checks that a `:vector` property's declared provider binding
7
+ # still matches the provider actually registered under that name.
8
+ #
9
+ # A `:vector` property may declare `provider:`, `model:`, and
10
+ # `dimensions:`. Only `provider:` was ever enforced. `dimensions:`
11
+ # is verified — but only against the vector a provider already
12
+ # returned, i.e. after the call has been made and paid for. And
13
+ # `model:` was never checked at all, which is the dangerous one: two
14
+ # generations of the same model family usually share a width
15
+ # (`voyage-3` and `voyage-3.5` are both 1024), so swapping the
16
+ # registered provider's model silently mixes incompatible
17
+ # embeddings into one index. Nothing raises, recall just quietly
18
+ # degrades, and the damage is only repairable by re-embedding.
19
+ #
20
+ # This module closes both gaps by comparing the declaration against
21
+ # the live provider BEFORE any request is issued.
22
+ #
23
+ # Auditing cannot happen at class-definition time: providers are
24
+ # registered by name and, as {Parse::Core::EmbedManaged} documents,
25
+ # registration may legitimately happen any time before the first
26
+ # save. So the audit runs lazily on each use and is also exposed as
27
+ # {.audit_all!} for an explicit boot-time or CI check.
28
+ module BindingAudit
29
+ # Raised when a property's declared binding disagrees with the
30
+ # registered provider.
31
+ class BindingMismatch < Parse::Embeddings::Error; end
32
+
33
+ # Raised when the audit cannot enumerate the classes it is meant
34
+ # to check. Distinct from {BindingMismatch}: nothing was found to
35
+ # be wrong, but nothing was verified either.
36
+ class DiscoveryFailed < Parse::Embeddings::Error; end
37
+
38
+ class << self
39
+ # Verify one property binding against a resolved provider.
40
+ #
41
+ # Deliberately NOT memoized. The check is a pair of comparisons
42
+ # against values already in memory, so caching it saves nothing
43
+ # measurable — while any cache key cheap enough to be worth
44
+ # computing (class name, provider object id) can go stale when a
45
+ # class is unloaded and redefined with a changed declaration, or
46
+ # when object ids are recycled. A validator that silently skips
47
+ # after a reload is worse than no validator, so correctness wins
48
+ # over an optimization with no observable benefit.
49
+ #
50
+ # @param klass [Class] the Parse::Object subclass.
51
+ # @param field [Symbol] the `:vector` property name.
52
+ # @param provider [Parse::Embeddings::Provider]
53
+ # @raise [BindingMismatch]
54
+ # @return [void]
55
+ def verify!(klass, field, provider)
56
+ declared = klass.vector_properties[field.to_sym]
57
+ return if declared.nil?
58
+
59
+ check!(klass, field, provider, declared)
60
+ nil
61
+ end
62
+
63
+ # Audit every declared binding whose provider is registered.
64
+ # Intended for boot or CI: it surfaces a drifted declaration
65
+ # before a single embedding is written, rather than on the
66
+ # first save that happens to touch it.
67
+ #
68
+ # @param classes [Array<Class>, nil] defaults to every
69
+ # Parse::Object subclass carrying `:vector` properties.
70
+ # @param strict [Boolean] when true, an unregistered provider
71
+ # is itself a failure; otherwise those bindings are skipped
72
+ # (a provider may be registered later in boot).
73
+ # @return [Array<String>] human-readable problems, empty when clean.
74
+ def audit_all!(classes: nil, strict: false)
75
+ problems = []
76
+ begin
77
+ bindings = collect_bindings(classes)
78
+ rescue DiscoveryFailed => e
79
+ return [e.message]
80
+ end
81
+
82
+ bindings.each do |klass, field, declared|
83
+ provider_name = declared[:provider]
84
+ next if provider_name.nil?
85
+
86
+ begin
87
+ provider = Parse::Embeddings.provider(provider_name)
88
+ rescue Parse::Embeddings::ProviderNotRegistered => e
89
+ problems << "#{klass}##{field}: #{e.message}" if strict
90
+ next
91
+ end
92
+
93
+ begin
94
+ check!(klass, field, provider, declared)
95
+ rescue BindingMismatch => e
96
+ problems << e.message
97
+ end
98
+ end
99
+ problems
100
+ end
101
+
102
+ # {.audit_all!} that raises instead of returning problems.
103
+ #
104
+ # @raise [BindingMismatch] when any binding disagrees.
105
+ # @return [void]
106
+ def audit_all_or_raise!(classes: nil, strict: false)
107
+ problems = audit_all!(classes: classes, strict: strict)
108
+ return if problems.empty?
109
+
110
+ raise BindingMismatch,
111
+ "Parse::Embeddings binding audit failed:\n - #{problems.join("\n - ")}"
112
+ end
113
+
114
+ # Retained as a no-op for callers that invoked it when this
115
+ # module memoized verdicts.
116
+ # @return [void]
117
+ def reset!
118
+ nil
119
+ end
120
+
121
+ private
122
+
123
+ # Read a provider accessor, distinguishing "not implemented"
124
+ # from a legitimate nil. Returns `:unavailable` for the former
125
+ # so {#check!} can fail closed rather than skip the comparison.
126
+ def accessor(provider, method)
127
+ value = provider.public_send(method)
128
+ value.nil? ? :unavailable : value
129
+ rescue NotImplementedError, NoMethodError
130
+ :unavailable
131
+ end
132
+
133
+ def check!(klass, field, provider, declared)
134
+ declared_model = declared[:model]
135
+ if declared_model
136
+ actual_model = accessor(provider, :model_name)
137
+ # A declaration states a requirement. A provider that cannot
138
+ # answer what model it runs cannot satisfy it, so this fails
139
+ # closed — otherwise a custom provider without `model_name`
140
+ # would write same-width embeddings that are never checked
141
+ # against the declaration at all.
142
+ if actual_model == :unavailable
143
+ raise BindingMismatch,
144
+ "#{klass}##{field} declares model: #{declared_model.inspect} but the " \
145
+ "provider registered as #{declared[:provider].inspect} " \
146
+ "(#{provider.class}) does not report a usable #model_name, so the " \
147
+ "binding cannot be verified. Implement #model_name on the provider, " \
148
+ "or drop `model:` from the property to opt out of the check."
149
+ end
150
+ if declared_model.to_s != actual_model.to_s
151
+ raise BindingMismatch,
152
+ "#{klass}##{field} declares model: #{declared_model.inspect} but the " \
153
+ "provider registered as #{declared[:provider].inspect} is running " \
154
+ "#{actual_model.inspect}. Vectors from different models are not " \
155
+ "comparable; embedding with the current provider would corrupt this " \
156
+ "index. Update the declaration and re-embed, or register the declared " \
157
+ "model."
158
+ end
159
+ end
160
+
161
+ declared_dims = declared[:dimensions]
162
+ if declared_dims
163
+ actual_dims = accessor(provider, :dimensions)
164
+ if actual_dims == :unavailable
165
+ raise BindingMismatch,
166
+ "#{klass}##{field} declares dimensions: #{declared_dims} but the " \
167
+ "provider registered as #{declared[:provider].inspect} " \
168
+ "(#{provider.class}) does not report a usable #dimensions, so the " \
169
+ "binding cannot be verified. #dimensions is required by the provider " \
170
+ "protocol."
171
+ end
172
+ if declared_dims != actual_dims
173
+ raise BindingMismatch,
174
+ "#{klass}##{field} declares dimensions: #{declared_dims} but the provider " \
175
+ "registered as #{declared[:provider].inspect} emits #{actual_dims}-dim " \
176
+ "vectors. Fix the declaration or configure the provider's width before " \
177
+ "embedding."
178
+ end
179
+ end
180
+ nil
181
+ end
182
+
183
+ def collect_bindings(classes)
184
+ list = classes || default_classes
185
+ list.flat_map do |klass|
186
+ next [] unless klass.respond_to?(:vector_properties)
187
+ klass.vector_properties.map { |field, declared| [klass, field, declared] }
188
+ end
189
+ end
190
+
191
+ # Discovery must NOT swallow its own failure: an empty list from
192
+ # a crashed sweep is indistinguishable from a clean audit, so
193
+ # `audit_all_or_raise!` would report success having checked
194
+ # nothing. Failures propagate as {DiscoveryFailed} and
195
+ # {.audit_all!} converts them into a reported problem.
196
+ def default_classes
197
+ return [] unless defined?(Parse::Object)
198
+ ObjectSpace.each_object(Class).select do |k|
199
+ k < Parse::Object && k.respond_to?(:vector_properties) &&
200
+ !k.vector_properties.empty?
201
+ end
202
+ rescue StandardError => e
203
+ raise DiscoveryFailed,
204
+ "Parse::Embeddings::BindingAudit could not enumerate Parse::Object " \
205
+ "subclasses (#{e.class}: #{e.message}); the audit checked nothing. " \
206
+ "Pass `classes:` explicitly to audit a known set."
207
+ end
208
+ end
209
+ end
210
+ end
211
+ end
@@ -0,0 +1,136 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ module Parse
5
+ module Embeddings
6
+ # A file-backed image or video input that is **streamed** into the
7
+ # request body rather than read into memory.
8
+ #
9
+ # This is the memory-safe counterpart to
10
+ # {ImageFetch::FetchedImage}, which holds raw bytes. A MediaFile
11
+ # holds only a path, a sniffed MIME type, and a byte count; the
12
+ # bytes are read and base64-encoded incrementally by
13
+ # {StreamingBody} while the request is being written to the socket.
14
+ # Peak memory stays at {StreamingBody::READ_CHUNK} no matter how
15
+ # large the file is, which is what makes video viable on a small
16
+ # dyno.
17
+ #
18
+ # Only the first 16 bytes are read at construction time, to sniff
19
+ # the container. The `Content-Type` header and the filename
20
+ # extension are never consulted.
21
+ #
22
+ # @example stream a local image
23
+ # img = Parse::Embeddings::MediaFile.image("diagram.png")
24
+ # provider.embed_image([img])
25
+ #
26
+ # @example stream a local video
27
+ # clip = Parse::Embeddings::MediaFile.video("demo.mp4")
28
+ # provider.embed_video([clip])
29
+ class MediaFile
30
+ # Voyage documents 20 MB per image and 20 MB per video.
31
+ # Overridable via {Parse::Embeddings.max_media_bytes=}.
32
+ DEFAULT_MAX_MEDIA_BYTES = 20 * 1024 * 1024
33
+
34
+ # Raised when a file exceeds {Parse::Embeddings.max_media_bytes}.
35
+ class TooLarge < Parse::Embeddings::Error; end
36
+
37
+ # @return [String] absolute path to the backing file.
38
+ attr_reader :path
39
+ # @return [String] sniffed MIME type.
40
+ attr_reader :mime_type
41
+ # @return [Integer] size in bytes, captured at construction.
42
+ attr_reader :byte_size
43
+ # @return [Symbol] `:image` or `:video`.
44
+ attr_reader :kind
45
+
46
+ class << self
47
+ # Wrap a local image file. Verifies the magic bytes against
48
+ # {Parse::Embeddings.allowed_image_types}.
49
+ #
50
+ # @param path [String]
51
+ # @return [MediaFile]
52
+ # @raise [ImageFetch::InvalidImageType]
53
+ def image(path)
54
+ header = read_header(path)
55
+ mime = ImageFetch.sniff_mime(header)
56
+ if mime.nil?
57
+ raise ImageFetch::InvalidImageType.new(:unknown_magic,
58
+ "Parse::Embeddings::MediaFile.image: #{path} matches no supported image " \
59
+ "format (JPEG/PNG/GIF/WebP).")
60
+ end
61
+ allowed = Parse::Embeddings.allowed_image_types
62
+ unless allowed.include?(mime)
63
+ raise ImageFetch::InvalidImageType.new(:type_not_allowed,
64
+ "Parse::Embeddings::MediaFile.image: sniffed type #{mime.inspect} is not in " \
65
+ "Parse::Embeddings.allowed_image_types (#{allowed.inspect}).")
66
+ end
67
+ new(path: path, mime_type: mime, kind: :image)
68
+ end
69
+
70
+ # Wrap a local video file. Verifies the magic bytes against
71
+ # {Parse::Embeddings.allowed_video_types}.
72
+ #
73
+ # @param path [String]
74
+ # @return [MediaFile]
75
+ # @raise [VideoSource::InvalidVideoType]
76
+ def video(path)
77
+ header = read_header(path)
78
+ new(path: path, mime_type: VideoSource.verify!(header), kind: :video)
79
+ end
80
+
81
+ private
82
+
83
+ # Read only enough bytes to sniff a container. Deliberately
84
+ # tiny — the whole point of this class is to never hold the
85
+ # file. A file shorter than this is passed through as-is so the
86
+ # sniffers can reject it with their own error.
87
+ def read_header(path)
88
+ unless ::File.file?(path)
89
+ raise ArgumentError,
90
+ "Parse::Embeddings::MediaFile: #{path.inspect} is not a readable file."
91
+ end
92
+ ::File.open(path, "rb") { |f| f.read(16).to_s }
93
+ end
94
+ end
95
+
96
+ def initialize(path:, mime_type:, kind:)
97
+ @path = ::File.expand_path(path)
98
+ @mime_type = mime_type
99
+ @kind = kind
100
+ @byte_size = ::File.size(@path)
101
+ if @byte_size.zero?
102
+ raise ArgumentError,
103
+ "Parse::Embeddings::MediaFile: #{path.inspect} is empty."
104
+ end
105
+ cap = Parse::Embeddings.max_media_bytes
106
+ if @byte_size > cap
107
+ raise TooLarge,
108
+ "Parse::Embeddings::MediaFile: #{path.inspect} is #{@byte_size} bytes, over " \
109
+ "the #{cap}-byte limit (Parse::Embeddings.max_media_bytes). Voyage rejects " \
110
+ "media above 20 MB — downscale or re-encode before embedding."
111
+ end
112
+ end
113
+
114
+ # The `data:` URI prefix that precedes the streamed base64 in the
115
+ # wire body. The payload itself is never concatenated here.
116
+ #
117
+ # @return [String]
118
+ def data_uri_prefix
119
+ "data:#{mime_type};base64,"
120
+ end
121
+
122
+ # Segment descriptor consumed by {StreamingBody}.
123
+ #
124
+ # @return [Hash]
125
+ def stream_segment
126
+ { path: @path, size: @byte_size }
127
+ end
128
+
129
+ def inspect
130
+ "#<Parse::Embeddings::MediaFile kind=#{@kind} mime_type=#{@mime_type.inspect} " \
131
+ "bytes=#{@byte_size} path=#{@path.inspect}>"
132
+ end
133
+ alias_method :to_s, :inspect
134
+ end
135
+ end
136
+ end
@@ -24,6 +24,7 @@ module Parse
24
24
  # Subclasses MAY override:
25
25
  #
26
26
  # * {#embed_image} — v5.1 (multimodal); default `NotImplementedError`
27
+ # * {#embed_video} — v5.6 (multimodal); default `NotImplementedError`
27
28
  # * {#embed_batch_size} — provider-recommended batch size hint
28
29
  # * {#max_input_tokens} — chunker hint
29
30
  # * {#normalize?} — whether output is unit-normalized
@@ -76,6 +77,44 @@ module Parse
76
77
  raise NotImplementedError, "#{self.class} does not support image embedding"
77
78
  end
78
79
 
80
+ # Embed video sources. Same contract and default posture as
81
+ # {#embed_image}: providers that do not offer video leave this
82
+ # raising, and callers discover support through {#modalities}
83
+ # rather than by rescuing.
84
+ #
85
+ # Video payloads are large enough that holding one in memory is a
86
+ # real operational risk, so the two source forms concrete
87
+ # providers should accept are:
88
+ #
89
+ # * a URL String, forwarded for provider-side fetch after
90
+ # {Parse::Embeddings.validate_image_url!} screens it (the SDK
91
+ # never downloads the video), and
92
+ # * a {Parse::Embeddings::MediaFile}, streamed into the request
93
+ # body by {Parse::Embeddings::StreamingBody} without ever being
94
+ # fully resident.
95
+ #
96
+ # Concrete overrides must accept `allow_insecure:` explicitly or
97
+ # absorb it via `**opts` — see {#embed_image} for why.
98
+ #
99
+ # @param sources [Array<String, Parse::Embeddings::MediaFile>]
100
+ # @param input_type [Symbol] `:search_query` or `:search_document`.
101
+ # @param allow_insecure [Boolean] forwarded to the URL validator.
102
+ # @param opts [Hash] provider-specific options.
103
+ # @return [Array<Array<Float>>] vectors aligned 1:1 with `sources`.
104
+ # @raise [NotImplementedError] unless the provider offers video.
105
+ def embed_video(sources, input_type: :search_document, allow_insecure: false, **opts)
106
+ raise NotImplementedError, "#{self.class} does not support video embedding"
107
+ end
108
+
109
+ # @return [Boolean] whether this provider accepts `modality`.
110
+ # Prefer this over rescuing {NotImplementedError} — it answers
111
+ # the question without issuing a call.
112
+ #
113
+ # @param modality [Symbol] one of `:text`, `:image`, `:video`.
114
+ def supports_modality?(modality)
115
+ modalities.include?(modality.to_sym)
116
+ end
117
+
79
118
  # Batched text embedding. Splits `strings` into chunks of size
80
119
  # {#embed_batch_size} (or returns a single-shot call when nil) and
81
120
  # concatenates results. Concrete providers should override only
@@ -0,0 +1,170 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "base64"
5
+
6
+ module Parse
7
+ module Embeddings
8
+ # An IO-shaped request body that splices base64-encoded files into
9
+ # a JSON envelope **without ever holding a whole file in memory**.
10
+ #
11
+ # Multimodal endpoints want one JSON document with the media inlined
12
+ # as a `data:` URI. Building that with `to_json` costs roughly 2.4x
13
+ # the media size resident (raw bytes + the 1.33x base64 copy + the
14
+ # serialized JSON String), which is enough to OOM a small dyno on a
15
+ # single moderate video. This class instead emits the body as a
16
+ # stream of segments — literal JSON fragments interleaved with
17
+ # files that are read and encoded {READ_CHUNK} bytes at a time —
18
+ # so peak memory is bounded by the chunk size regardless of how
19
+ # large the media is, and nothing is spilled to disk either.
20
+ #
21
+ # Faraday's net_http adapter assigns any body responding to `#read`
22
+ # to `Net::HTTP::Request#body_stream`, which pulls it incrementally.
23
+ # {#size} is exact, so callers can set `Content-Length` and avoid
24
+ # chunked transfer encoding (which some API gateways reject).
25
+ #
26
+ # Base64 is spliced directly into the JSON string literal with no
27
+ # escaping: the alphabet (`A-Za-z0-9+/=`) contains no character
28
+ # that JSON requires escaping, so this is safe by construction.
29
+ class StreamingBody
30
+ # Bytes read from a source file per fill. MUST stay a multiple of
31
+ # 3 so each chunk encodes to a padding-free base64 block and the
32
+ # concatenation is byte-identical to encoding the whole file at
33
+ # once. Only the final (short) chunk may carry `=` padding.
34
+ READ_CHUNK = 57 * 1024
35
+
36
+ # Raised when a segment's file changes size between the
37
+ # {#size} calculation and the actual read, which would desync
38
+ # `Content-Length` from the emitted body.
39
+ class SizeMismatch < Parse::Embeddings::Error; end
40
+
41
+ # @param segments [Array<String, Hash>] literal Strings are
42
+ # emitted verbatim; Hashes of the form
43
+ # `{ path: String, size: Integer }` are base64-streamed.
44
+ def initialize(segments)
45
+ @segments = segments.map do |seg|
46
+ case seg
47
+ when String then seg.dup.force_encoding(Encoding::BINARY)
48
+ when Hash
49
+ unless seg[:path].is_a?(String) && seg[:size].is_a?(Integer)
50
+ raise ArgumentError,
51
+ "Parse::Embeddings::StreamingBody: file segment needs :path and :size."
52
+ end
53
+ seg
54
+ else
55
+ raise ArgumentError,
56
+ "Parse::Embeddings::StreamingBody: segment must be a String or " \
57
+ "{path:, size:} Hash (got #{seg.class})."
58
+ end
59
+ end
60
+ rewind
61
+ end
62
+
63
+ # Exact byte length of the fully-emitted body. Base64 expands
64
+ # every 3 input bytes to 4 output bytes, padded up.
65
+ #
66
+ # @return [Integer]
67
+ def size
68
+ @size ||= @segments.sum do |seg|
69
+ seg.is_a?(String) ? seg.bytesize : 4 * ((seg[:size] + 2) / 3)
70
+ end
71
+ end
72
+ alias_method :length, :size
73
+
74
+ # @param len [Integer, nil] bytes wanted; nil reads to the end
75
+ # (which defeats the memory bound — Net::HTTP always passes a
76
+ # length, so this is only for completeness).
77
+ # @param out [String, nil] optional output buffer to fill.
78
+ # @return [String, nil] nil once exhausted, per IO#read semantics.
79
+ def read(len = nil, out = nil)
80
+ fill(len)
81
+ if @buffer.empty?
82
+ out&.clear
83
+ return len.nil? ? "" : nil
84
+ end
85
+
86
+ chunk =
87
+ if len.nil?
88
+ b = @buffer
89
+ @buffer = +""
90
+ b
91
+ else
92
+ @buffer.slice!(0, len)
93
+ end
94
+
95
+ if out
96
+ out.replace(chunk)
97
+ out
98
+ else
99
+ chunk
100
+ end
101
+ end
102
+
103
+ # Reset to the start so the body can be replayed (Net::HTTP
104
+ # rewinds `body_stream` when it retries a request).
105
+ #
106
+ # @return [void]
107
+ def rewind
108
+ close
109
+ @buffer = +""
110
+ @index = 0
111
+ @io = nil
112
+ nil
113
+ end
114
+
115
+ # @return [void]
116
+ def close
117
+ @io&.close
118
+ @io = nil
119
+ nil
120
+ end
121
+
122
+ private
123
+
124
+ # Top up @buffer until it holds `len` bytes or the segments run
125
+ # out. Reads at most one READ_CHUNK per iteration, so peak
126
+ # memory is READ_CHUNK * 4/3 plus whatever the caller asked for.
127
+ def fill(len)
128
+ loop do
129
+ return if len && @buffer.bytesize >= len
130
+ return if @index >= @segments.length
131
+
132
+ seg = @segments[@index]
133
+ if seg.is_a?(String)
134
+ @buffer << seg
135
+ @index += 1
136
+ next
137
+ end
138
+
139
+ @io ||= begin
140
+ f = ::File.open(seg[:path], "rb")
141
+ actual = f.size
142
+ if actual != seg[:size]
143
+ f.close
144
+ raise SizeMismatch,
145
+ "Parse::Embeddings::StreamingBody: #{seg[:path]} is #{actual} bytes but " \
146
+ "#{seg[:size]} was declared; the file changed underneath the request."
147
+ end
148
+ f
149
+ end
150
+
151
+ data = @io.read(READ_CHUNK)
152
+ if data.nil? || data.empty?
153
+ @io.close
154
+ @io = nil
155
+ @index += 1
156
+ next
157
+ end
158
+ @buffer << Base64.strict_encode64(data)
159
+ # A short read means EOF; close now so the padding lands and
160
+ # the next iteration advances to the following segment.
161
+ if data.bytesize < READ_CHUNK
162
+ @io.close
163
+ @io = nil
164
+ @index += 1
165
+ end
166
+ end
167
+ end
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,120 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "base64"
5
+
6
+ module Parse
7
+ module Embeddings
8
+ # Value objects and magic-byte verification for video inputs to
9
+ # multimodal embedding providers.
10
+ #
11
+ # This mirrors {ImageFetch} deliberately — same sniff-then-verify
12
+ # discipline, same refusal to trust a `Content-Type` header — but
13
+ # it deliberately ships NO byte-holding value object and NO
14
+ # `fetch!` counterpart. Video payloads are large enough that
15
+ # holding one in memory can exhaust a small dyno, so both supported
16
+ # paths avoid it:
17
+ #
18
+ # * **local file** — wrap with {Parse::Embeddings::MediaFile.video},
19
+ # which reads only the 16-byte header. The bytes are base64-
20
+ # streamed into the request by {StreamingBody} at send time.
21
+ # * **URL** — pass the URL String straight to `embed_video`, which
22
+ # validates it through {Parse::Embeddings.validate_image_url!}
23
+ # (a generic URL-safety guard despite the name: CIDR screen, port
24
+ # and host allowlist, sentinel-gated egress) and lets the
25
+ # provider do the fetch. The SDK never downloads the video.
26
+ module VideoSource
27
+ # Raised when bytes fail verification — unknown magic, or a
28
+ # sniffed type outside the allowlist. Carries a `:reason` tag
29
+ # (`:empty`, `:unknown_magic`, `:type_not_allowed`) matching
30
+ # {ImageFetch::InvalidImageType}'s convention.
31
+ class InvalidVideoType < Parse::Embeddings::Error
32
+ # @return [Symbol] failure-mode tag.
33
+ attr_reader :reason
34
+ def initialize(reason, message)
35
+ @reason = reason
36
+ super(message)
37
+ end
38
+ end
39
+
40
+ # MIME types accepted by default. Voyage documents MP4 as the
41
+ # only supported video container, and the live API rejects WebM
42
+ # and QuickTime payloads outright — so accepting them here would
43
+ # only trade a clear local error for an opaque provider 400.
44
+ # {.sniff_mime} still *recognizes* those containers so the
45
+ # rejection can name what was actually supplied.
46
+ DEFAULT_ALLOWED_VIDEO_TYPES = %w[video/mp4].freeze
47
+
48
+ # ISO Base Media major brands, split by the MIME type they imply.
49
+ # An `ftyp` box alone does NOT mean MP4 — QuickTime and the
50
+ # audio-only profiles share the container — so brands are matched
51
+ # explicitly and an unknown brand sniffs as nil rather than being
52
+ # assumed to be MP4.
53
+ #
54
+ # `M4A` is deliberately absent: it is Apple's audio-only profile,
55
+ # and admitting it here would let an audio file pass as video and
56
+ # be sent to a provider that accepts neither.
57
+ MP4_BRANDS = %w[isom iso2 iso4 iso5 iso6 mp41 mp42 avc1 dash mmp4 M4V].freeze
58
+ QUICKTIME_BRANDS = %w[qt].freeze
59
+
60
+ module_function
61
+
62
+ # Determine a video's MIME type from its leading magic bytes.
63
+ # Returns nil for anything unrecognized — callers must treat nil
64
+ # as a refusal and never fall back to extension or header typing.
65
+ #
66
+ # @param bytes [String] raw video bytes (at least the first 12).
67
+ # @return [String, nil] sniffed MIME type, or nil when unknown.
68
+ def sniff_mime(bytes)
69
+ return nil unless bytes.is_a?(String) && bytes.bytesize >= 12
70
+ b = bytes.byteslice(0, 16).force_encoding(Encoding::BINARY)
71
+
72
+ # Matroska / WebM share an EBML header; WebM is the profile
73
+ # every multimodal provider documents, so report it as WebM.
74
+ return "video/webm" if b.start_with?("\x1A\x45\xDF\xA3".b)
75
+
76
+ # ISO Base Media File Format: a size-prefixed `ftyp` box. The
77
+ # 4-byte major brand at offset 8 separates QuickTime from MP4.
78
+ # Unknown brands return nil — guessing "MP4" for an arbitrary
79
+ # ISO-BMFF file sends the provider something it will reject.
80
+ if b.byteslice(4, 4) == "ftyp".b
81
+ brand = b.byteslice(8, 4).to_s.strip
82
+ return "video/quicktime" if QUICKTIME_BRANDS.include?(brand)
83
+ return "video/mp4" if MP4_BRANDS.include?(brand)
84
+ return nil
85
+ end
86
+
87
+ nil
88
+ end
89
+
90
+ # Sniff the magic bytes and check the allowlist. Only the leading
91
+ # bytes are inspected, so callers may pass a short header slice
92
+ # rather than the whole file — which is what
93
+ # {Parse::Embeddings::MediaFile.video} does.
94
+ #
95
+ # @param bytes [String] leading video bytes (at least 12).
96
+ # @return [String] the sniffed MIME type.
97
+ # @raise [InvalidVideoType]
98
+ def verify!(bytes)
99
+ if bytes.nil? || bytes.empty?
100
+ raise InvalidVideoType.new(:empty,
101
+ "Parse::Embeddings::VideoSource: video payload is empty.")
102
+ end
103
+ mime = sniff_mime(bytes)
104
+ if mime.nil?
105
+ raise InvalidVideoType.new(:unknown_magic,
106
+ "Parse::Embeddings::VideoSource: leading bytes match no supported video " \
107
+ "container (MP4/QuickTime/WebM). The Content-Type header is not consulted — " \
108
+ "unrecognized content is refused outright.")
109
+ end
110
+ allowed = Parse::Embeddings.allowed_video_types
111
+ unless allowed.include?(mime)
112
+ raise InvalidVideoType.new(:type_not_allowed,
113
+ "Parse::Embeddings::VideoSource: sniffed type #{mime.inspect} is not in " \
114
+ "Parse::Embeddings.allowed_video_types (#{allowed.inspect}).")
115
+ end
116
+ mime
117
+ end
118
+ end
119
+ end
120
+ end