lipdub 0.1.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,400 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ module Lipdub
6
+ module Resources
7
+ class Shots < Base
8
+ # List all available shots
9
+ #
10
+ # @param page [Integer] Page number for pagination (defaults to 1)
11
+ # @param per_page [Integer] Number of items per page, max 100 (defaults to 20)
12
+ # @return [Hash] Response containing list of shots and count
13
+ #
14
+ # @example
15
+ # shots = client.shots.list(page: 1, per_page: 50)
16
+ # # => {
17
+ # # "data" => [
18
+ # # {
19
+ # # "shot_id" => 99,
20
+ # # "shot_label" => "api-full-test-new.mp4",
21
+ # # "shot_project_id" => 37,
22
+ # # "shot_scene_id" => 37,
23
+ # # "shot_project_name" => "Lee Studios",
24
+ # # "shot_scene_name" => "Under the tent"
25
+ # # }
26
+ # # ],
27
+ # # "count" => 1
28
+ # # }
29
+ def list(page: 1, per_page: 20)
30
+ validate_pagination_params!(page, per_page)
31
+
32
+ params = {
33
+ page: page,
34
+ per_page: per_page
35
+ }
36
+ get("/v1/shots", params)
37
+ end
38
+
39
+ # Get shot processing status
40
+ #
41
+ # @param shot_id [String, Integer] Unique identifier of the shot
42
+ # @return [Hash] Response containing shot status and processing information
43
+ #
44
+ # @example
45
+ # status = client.shots.status(123)
46
+ def status(shot_id)
47
+ get("/v1/shots/#{shot_id}/status")
48
+ end
49
+
50
+ # Generate lip-dubbed video
51
+ #
52
+ # @param shot_id [String, Integer] Unique identifier of the shot
53
+ # @param audio_id [String] Unique identifier of the audio file
54
+ # @param output_filename [String] Name for the output file
55
+ # @param language [String, nil] Optional language specification (ISO 639-1)
56
+ # @param start_frame [Integer, nil] Frame number to start the lip-sync from (defaults to 0)
57
+ # @param loop_video [Boolean, nil] Whether to loop the video during rendering (defaults to false)
58
+ # @param full_resolution [Boolean, nil] Whether to use full resolution (defaults to true)
59
+ # @param callback_url [String, nil] Optional HTTPS URL for completion callback
60
+ # @param timecode_ranges [Array, nil] Optional list of timecode ranges to render
61
+ # @return [Hash] Response containing generation details and generate_id
62
+ #
63
+ # @example
64
+ # response = client.shots.generate(
65
+ # shot_id: 123,
66
+ # audio_id: "audio_456",
67
+ # output_filename: "dubbed_video.mp4",
68
+ # language: "en-US",
69
+ # start_frame: 0,
70
+ # loop_video: false,
71
+ # full_resolution: true,
72
+ # callback_url: "https://example.com/webhook"
73
+ # )
74
+ # # => {
75
+ # # "generate_id" => 456
76
+ # # }
77
+ def generate(shot_id:, audio_id:, output_filename:, language: nil, start_frame: nil,
78
+ loop_video: nil, full_resolution: nil, callback_url: nil, timecode_ranges: nil)
79
+ body = {
80
+ audio_id: audio_id,
81
+ output_filename: output_filename
82
+ }
83
+
84
+ body[:language] = language if language
85
+ body[:start_frame] = start_frame if start_frame
86
+ body[:loop_video] = loop_video unless loop_video.nil?
87
+ body[:full_resolution] = full_resolution unless full_resolution.nil?
88
+ body[:callback_url] = callback_url if callback_url
89
+ body[:timecode_ranges] = timecode_ranges if timecode_ranges
90
+
91
+ post("/v1/shots/#{shot_id}/generate", body)
92
+ end
93
+
94
+ # Get generation status
95
+ #
96
+ # @param shot_id [String, Integer] Unique identifier of the shot
97
+ # @param generate_id [String] Unique identifier of the generation request
98
+ # @return [Hash] Response containing generation progress and status
99
+ #
100
+ # @example
101
+ # status = client.shots.generation_status(123, "gen_789")
102
+ def generation_status(shot_id, generate_id)
103
+ get("/v1/shots/#{shot_id}/generate/#{generate_id}")
104
+ end
105
+
106
+ # Download generated video
107
+ #
108
+ # @param shot_id [String, Integer] Unique identifier of the shot
109
+ # @param generate_id [String] Unique identifier of the generation request
110
+ # @return [Hash] Response containing download_url for the dubbed video
111
+ #
112
+ # @example
113
+ # download_info = client.shots.download(123, "gen_789")
114
+ # # => {
115
+ # # "data" => {
116
+ # # "download_url" => "https://storage.lipdub.ai/download/gen_789?token=xyz"
117
+ # # }
118
+ # # }
119
+ def download(shot_id, generate_id)
120
+ get("/v1/shots/#{shot_id}/generate/#{generate_id}/download")
121
+ end
122
+
123
+ # Download generated video file directly to a local path
124
+ #
125
+ # @param shot_id [String, Integer] Unique identifier of the shot
126
+ # @param generate_id [String] Unique identifier of the generation request
127
+ # @param file_path [String] Local path where the video should be saved
128
+ # @return [String] Path to the downloaded file
129
+ #
130
+ # @example
131
+ # local_path = client.shots.download_file(123, "gen_789", "output/dubbed_video.mp4")
132
+ # # Downloads the file and returns "output/dubbed_video.mp4"
133
+ def download_file(shot_id, generate_id, file_path)
134
+ download_response = download(shot_id, generate_id)
135
+ download_url = download_response.dig("data", "download_url") if download_response.is_a?(Hash)
136
+
137
+ # Handle case where response might still be a string (fallback)
138
+ if download_response.is_a?(String)
139
+ parsed = JSON.parse(download_response)
140
+ download_url = parsed.dig("data", "download_url")
141
+ end
142
+
143
+ raise APIError, "Download URL not found in response" unless download_url
144
+
145
+ download_file_from_url(download_url, file_path)
146
+ end
147
+
148
+ # Complete generation workflow: generate and wait for completion
149
+ #
150
+ # @param shot_id [String, Integer] Unique identifier of the shot
151
+ # @param audio_id [String] Unique identifier of the audio file
152
+ # @param output_filename [String] Name for the output file
153
+ # @param language [String, nil] Optional language specification (ISO 639-1)
154
+ # @param start_frame [Integer, nil] Frame number to start the lip-sync from (defaults to 0)
155
+ # @param loop_video [Boolean, nil] Whether to loop the video during rendering (defaults to false)
156
+ # @param full_resolution [Boolean, nil] Whether to use full resolution (defaults to true)
157
+ # @param callback_url [String, nil] Optional HTTPS URL for completion callback
158
+ # @param timecode_ranges [Array, nil] Optional list of timecode ranges to render
159
+ # @param polling_interval [Integer] Seconds to wait between status checks (default: 10)
160
+ # @param max_wait_time [Integer] Maximum seconds to wait for completion (default: 1800)
161
+ # @return [Hash] Response containing final generation status
162
+ #
163
+ # @example
164
+ # result = client.shots.generate_and_wait(
165
+ # shot_id: 123,
166
+ # audio_id: "audio_456",
167
+ # output_filename: "dubbed_video.mp4",
168
+ # polling_interval: 15,
169
+ # max_wait_time: 3600
170
+ # )
171
+ def generate_and_wait(shot_id:, audio_id:, output_filename:, language: nil,
172
+ start_frame: nil, loop_video: nil, full_resolution: nil,
173
+ callback_url: nil, timecode_ranges: nil, polling_interval: 10, max_wait_time: 1800)
174
+ # Start generation
175
+ generate_response = generate(
176
+ shot_id: shot_id,
177
+ audio_id: audio_id,
178
+ output_filename: output_filename,
179
+ language: language,
180
+ start_frame: start_frame,
181
+ loop_video: loop_video,
182
+ full_resolution: full_resolution,
183
+ callback_url: callback_url,
184
+ timecode_ranges: timecode_ranges
185
+ )
186
+
187
+ generate_id = nil
188
+ if generate_response.is_a?(Hash)
189
+ generate_id = generate_response["generate_id"] || generate_response.dig("data", "generate_id")
190
+ elsif generate_response.is_a?(String)
191
+ parsed = JSON.parse(generate_response)
192
+ generate_id = parsed["generate_id"] || parsed.dig("data", "generate_id")
193
+ end
194
+
195
+ raise APIError, "Generate ID not found in response" unless generate_id
196
+
197
+ # Poll for completion
198
+ start_time = Time.now
199
+ loop do
200
+ status_response = generation_status(shot_id, generate_id)
201
+
202
+ # Handle both Hash and String responses
203
+ status = nil
204
+ if status_response.is_a?(Hash)
205
+ status = status_response.dig("data", "status") || status_response["status"]
206
+ elsif status_response.is_a?(String)
207
+ parsed = JSON.parse(status_response)
208
+ status = parsed.dig("data", "status") || parsed["status"]
209
+ status_response = parsed # Use parsed version for return
210
+ end
211
+
212
+ case status
213
+ when "completed", "success"
214
+ return status_response
215
+ when "failed", "error"
216
+ raise APIError, "Generation failed: #{status_response}"
217
+ end
218
+
219
+ # Check timeout
220
+ if Time.now - start_time > max_wait_time
221
+ raise TimeoutError, "Generation did not complete within #{max_wait_time} seconds"
222
+ end
223
+
224
+ sleep(polling_interval)
225
+ end
226
+ end
227
+
228
+ # Get actors for a shot
229
+ #
230
+ # @param shot_id [String, Integer] Unique identifier of the shot
231
+ # @return [Hash] Response containing actors information for the shot
232
+ #
233
+ # @example
234
+ # actors = client.shots.actors(123)
235
+ def actors(shot_id)
236
+ get("/v1/shots/#{shot_id}/actors")
237
+ end
238
+
239
+ # Translate a LipDub for a shot
240
+ #
241
+ # @param shot_id [String, Integer] Unique identifier of the shot
242
+ # @param source_language [String] Source language code
243
+ # @param target_language [String] Target language code
244
+ # @param full_resolution [Boolean, nil] Whether to render in full resolution (defaults to true)
245
+ # @return [Hash] Response containing translation details
246
+ #
247
+ # @example
248
+ # response = client.shots.translate(
249
+ # shot_id: 123,
250
+ # source_language: "English",
251
+ # target_language: "Spanish",
252
+ # full_resolution: true
253
+ # )
254
+ def translate(shot_id:, source_language:, target_language:, full_resolution: nil)
255
+ body = {
256
+ source_language: source_language,
257
+ target_language: target_language
258
+ }
259
+ body[:full_resolution] = full_resolution unless full_resolution.nil?
260
+
261
+ post("/v1/shots/#{shot_id}/translate", body)
262
+ end
263
+
264
+ # Generate multi-actor LipDub for a shot
265
+ #
266
+ # @param shot_id [String, Integer] Unique identifier of the shot
267
+ # @param params [Hash] Multi-actor generation parameters
268
+ # @return [Hash] Response containing multi-actor generation details
269
+ #
270
+ # @example
271
+ # response = client.shots.generate_multi_actor(
272
+ # shot_id: 123,
273
+ # params: { actors: [...] }
274
+ # )
275
+ def generate_multi_actor(shot_id:, **params)
276
+ post("/v1/shots/#{shot_id}/generate-multi-actor", params)
277
+ end
278
+
279
+ # Helper method to validate timecode ranges
280
+
281
+ # Validates timecode ranges for selective lip-dubbing
282
+ # @param ranges [Array<Array>] Array of [start, end] timecode pairs
283
+ # @param video_duration [Numeric] Total video duration in seconds (optional)
284
+ # @return [Boolean] true if valid
285
+ # @raise [ArgumentError] if ranges are invalid
286
+ def validate_timecode_ranges(ranges, video_duration: nil)
287
+ return true if ranges.nil? || ranges.empty?
288
+
289
+ unless ranges.is_a?(Array)
290
+ raise ArgumentError, "timecode_ranges must be an array"
291
+ end
292
+
293
+ ranges.each_with_index do |range, index|
294
+ unless range.is_a?(Array) && range.length == 2
295
+ raise ArgumentError, "Each timecode range must be an array of [start, end] at index #{index}"
296
+ end
297
+
298
+ start_time, end_time = range
299
+ start_seconds = parse_timecode_to_seconds(start_time)
300
+ end_seconds = parse_timecode_to_seconds(end_time)
301
+
302
+ if start_seconds >= end_seconds
303
+ raise ArgumentError, "Start time must be before end time in range #{index}: #{range}"
304
+ end
305
+
306
+ if video_duration && end_seconds > video_duration
307
+ raise ArgumentError, "End time #{end_time} exceeds video duration #{video_duration} in range #{index}"
308
+ end
309
+ end
310
+
311
+ # Check for overlapping ranges
312
+ sorted_ranges = ranges.map { |r| [parse_timecode_to_seconds(r[0]), parse_timecode_to_seconds(r[1])] }
313
+ .sort_by(&:first)
314
+
315
+ sorted_ranges.each_cons(2) do |(prev_start, prev_end), (curr_start, curr_end)|
316
+ if curr_start < prev_end
317
+ raise ArgumentError, "Overlapping timecode ranges detected: [#{prev_start}, #{prev_end}] and [#{curr_start}, #{curr_end}]"
318
+ end
319
+ end
320
+
321
+ true
322
+ end
323
+
324
+ # Adds frame buffer to timecode ranges for seamless blending
325
+ # @param ranges [Array<Array>] Array of [start, end] timecode pairs
326
+ # @param buffer_frames [Integer] Number of frames to add as buffer (default: 10)
327
+ # @param fps [Integer] Frames per second (default: 30)
328
+ # @param video_duration [Numeric] Total video duration to clamp end times (optional)
329
+ # @return [Array<Array>] Buffered timecode ranges
330
+ def add_frame_buffer(ranges, buffer_frames: 10, fps: 30, video_duration: nil)
331
+ return ranges if ranges.nil? || ranges.empty?
332
+
333
+ buffer_seconds = buffer_frames.to_f / fps
334
+
335
+ ranges.map do |start_time, end_time|
336
+ start_seconds = parse_timecode_to_seconds(start_time, fps: fps)
337
+ end_seconds = parse_timecode_to_seconds(end_time, fps: fps)
338
+
339
+ buffered_start = [start_seconds - buffer_seconds, 0.0].max
340
+ buffered_end = end_seconds + buffer_seconds
341
+
342
+ if video_duration
343
+ buffered_end = [buffered_end, video_duration].min
344
+ end
345
+
346
+ [buffered_start, buffered_end]
347
+ end
348
+ end
349
+
350
+ # Converts timecode to seconds
351
+ # @param timecode [String, Numeric] Either numeric seconds or SMPTE format "HH:MM:SS:FF"
352
+ # @param fps [Integer] Frames per second for SMPTE conversion (default: 30)
353
+ # @return [Float] Time in seconds
354
+ def parse_timecode_to_seconds(timecode, fps: 30)
355
+ case timecode
356
+ when Numeric
357
+ timecode.to_f
358
+ when String
359
+ if timecode.match?(/^\d{2}:\d{2}:\d{2}:\d{2}$/)
360
+ # SMPTE format: HH:MM:SS:FF
361
+ hours, minutes, seconds, frames = timecode.split(':').map(&:to_i)
362
+ hours * 3600 + minutes * 60 + seconds + frames.to_f / fps
363
+ else
364
+ # Try parsing as float string
365
+ timecode.to_f
366
+ end
367
+ else
368
+ raise ArgumentError, "Invalid timecode format: #{timecode}. Use numeric seconds or SMPTE format (HH:MM:SS:FF)"
369
+ end
370
+ end
371
+
372
+ private
373
+
374
+ def validate_pagination_params!(page, per_page)
375
+ raise ValidationError, "Page must be >= 1" if page < 1
376
+ raise ValidationError, "Per page must be between 1 and 100" unless (1..100).include?(per_page)
377
+ end
378
+
379
+ def download_file_from_url(url, file_path)
380
+ # Create directory if it doesn't exist
381
+ FileUtils.mkdir_p(File.dirname(file_path))
382
+
383
+ connection = Faraday.new do |conn|
384
+ conn.adapter Faraday.default_adapter
385
+ end
386
+
387
+ response = connection.get(url)
388
+
389
+ if response.success?
390
+ File.open(file_path, 'wb') do |file|
391
+ file.write(response.body)
392
+ end
393
+ file_path
394
+ else
395
+ raise APIError, "Failed to download file: HTTP #{response.status}"
396
+ end
397
+ end
398
+ end
399
+ end
400
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lipdub
4
+ module Resources
5
+ class Videos < Base
6
+ # Initiate video upload process
7
+ #
8
+ # @param size_bytes [Integer] Size of the video file in bytes
9
+ # @param file_name [String] Name of the video file with extension
10
+ # @param content_type [String] MIME type of the video file
11
+ # @param video_source_url [String, nil] Optional URL of the video source file
12
+ # @return [Hash] Response containing video_id, upload_url, success_url, and failure_url
13
+ #
14
+ # @example
15
+ # response = client.videos.upload(
16
+ # size_bytes: 52428800,
17
+ # file_name: "sample.mp4",
18
+ # content_type: "video/mp4"
19
+ # )
20
+ # # => {
21
+ # # "data" => {
22
+ # # "video_id" => "video_123",
23
+ # # "upload_url" => "https://storage.lipdub.ai/upload/video_123?token=xyz",
24
+ # # "success_url" => "https://api.lipdub.ai/v1/video/success/video_123",
25
+ # # "failure_url" => "https://api.lipdub.ai/v1/video/failure/video_123"
26
+ # # }
27
+ # # }
28
+ def upload(size_bytes:, file_name:, content_type:, video_source_url: nil)
29
+ body = {
30
+ size_bytes: size_bytes,
31
+ file_name: file_name,
32
+ content_type: content_type
33
+ }
34
+ body[:video_source_url] = video_source_url if video_source_url
35
+
36
+ post("/v1/video", body)
37
+ end
38
+
39
+ # Upload video file to the provided upload URL
40
+ #
41
+ # @param upload_url [String] The upload URL received from the upload method
42
+ # @param file_content [String, IO] The video file content to upload
43
+ # @param content_type [String] MIME type of the video file
44
+ # @return [Hash] Response from the upload
45
+ #
46
+ # @example
47
+ # file_content = File.read("sample.mp4")
48
+ # client.videos.upload_file(upload_url, file_content, "video/mp4")
49
+ def upload_file(upload_url, file_content, content_type)
50
+ put_file(upload_url, file_content, content_type)
51
+ end
52
+
53
+ # Complete video upload process with a file path
54
+ #
55
+ # @param file_path [String] Path to the video file
56
+ # @param content_type [String, nil] MIME type of the video file (auto-detected if nil)
57
+ # @return [Hash] Response containing shot_id and asset_type after successful upload
58
+ #
59
+ # @example
60
+ # response = client.videos.upload_complete("path/to/video.mp4")
61
+ # # This method handles the entire upload flow:
62
+ # # 1. Initiates upload
63
+ # # 2. Uploads the file
64
+ # # 3. Calls success callback
65
+ def upload_complete(file_path, content_type: nil)
66
+ raise ArgumentError, "File does not exist: #{file_path}" unless File.exist?(file_path)
67
+
68
+ file_content = File.read(file_path)
69
+ file_name = File.basename(file_path)
70
+ content_type ||= detect_content_type(file_path)
71
+ size_bytes = File.size(file_path)
72
+
73
+ # Step 1: Initiate upload
74
+ upload_response = upload(
75
+ size_bytes: size_bytes,
76
+ file_name: file_name,
77
+ content_type: content_type
78
+ )
79
+
80
+ video_id = upload_response.dig("data", "video_id") if upload_response.is_a?(Hash)
81
+ upload_url = upload_response.dig("data", "upload_url") if upload_response.is_a?(Hash)
82
+
83
+ # Handle case where response might still be a string (fallback)
84
+ if upload_response.is_a?(String)
85
+ parsed = JSON.parse(upload_response)
86
+ video_id = parsed.dig("data", "video_id")
87
+ upload_url = parsed.dig("data", "upload_url")
88
+ end
89
+
90
+ begin
91
+ # Step 2: Upload file
92
+ upload_file(upload_url, file_content, content_type)
93
+
94
+ # Step 3: Mark as successful
95
+ success(video_id)
96
+ rescue => e
97
+ # Step 3 (alternative): Mark as failed
98
+ failure(video_id)
99
+ raise e
100
+ end
101
+ end
102
+
103
+ # Mark video upload as successful
104
+ #
105
+ # @param video_id [String] Unique identifier of the video file
106
+ # @return [Hash] Response containing shot_id and asset_type
107
+ #
108
+ # @example
109
+ # response = client.videos.success("video_123")
110
+ # # => {
111
+ # # "data" => {
112
+ # # "shot_id" => 123,
113
+ # # "asset_type" => "dubbing-video"
114
+ # # }
115
+ # # }
116
+ def success(video_id)
117
+ post("/v1/video/success/#{video_id}")
118
+ end
119
+
120
+ # Mark video upload as failed
121
+ #
122
+ # @param video_id [String] Unique identifier of the video file
123
+ # @return [Hash] Response (typically empty)
124
+ #
125
+ # @example
126
+ # client.videos.failure("video_123")
127
+ def failure(video_id)
128
+ post("/v1/video/failure/#{video_id}")
129
+ end
130
+
131
+ # Get video processing status
132
+ #
133
+ # @param video_id [String] Unique identifier of the video file
134
+ # @return [Hash] Response containing video status information
135
+ #
136
+ # @example
137
+ # status = client.videos.status("video_123")
138
+ def status(video_id)
139
+ get("/v1/video/status/#{video_id}")
140
+ end
141
+
142
+ private
143
+
144
+ def detect_content_type(file_path)
145
+ extension = File.extname(file_path).downcase
146
+ case extension
147
+ when '.mp4'
148
+ 'video/mp4'
149
+ when '.mov'
150
+ 'video/quicktime'
151
+ when '.avi'
152
+ 'video/x-msvideo'
153
+ when '.webm'
154
+ 'video/webm'
155
+ when '.mkv'
156
+ 'video/x-matroska'
157
+ else
158
+ 'video/mp4' # Default fallback
159
+ end
160
+ end
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lipdub
4
+ VERSION = "0.1.0"
5
+ end
data/lib/lipdub.rb ADDED
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "faraday/multipart"
5
+ require "json"
6
+
7
+ require_relative "lipdub/version"
8
+ require_relative "lipdub/configuration"
9
+ require_relative "lipdub/client"
10
+ require_relative "lipdub/errors"
11
+ require_relative "lipdub/resources/base"
12
+ require_relative "lipdub/resources/videos"
13
+ require_relative "lipdub/resources/audios"
14
+ require_relative "lipdub/resources/shots"
15
+ require_relative "lipdub/resources/projects"
16
+
17
+ module Lipdub
18
+ class << self
19
+ attr_accessor :configuration
20
+ end
21
+
22
+ def self.configuration
23
+ @configuration ||= Configuration.new
24
+ end
25
+
26
+ def self.configure
27
+ yield(configuration)
28
+ end
29
+
30
+ def self.client
31
+ @client ||= Client.new
32
+ end
33
+ end
data/lipdub.gemspec ADDED
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/lipdub/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "lipdub"
7
+ spec.version = Lipdub::VERSION
8
+ spec.authors = ["Upriser"]
9
+ spec.email = ["support@upriser.com"]
10
+
11
+ spec.summary = "Ruby client library for Lipdub.ai API"
12
+ spec.description = "A comprehensive Ruby client for interacting with the Lipdub.ai API, providing video and audio upload capabilities, AI-powered lip-dubbing generation, and processing status monitoring."
13
+ spec.homepage = "https://github.com/upriser/lipdub-ruby"
14
+ spec.license = "MIT"
15
+ spec.required_ruby_version = ">= 2.7.0"
16
+
17
+ spec.metadata["allowed_push_host"] = "https://rubygems.org"
18
+ spec.metadata["homepage_uri"] = spec.homepage
19
+ spec.metadata["source_code_uri"] = "https://github.com/upriser/lipdub-ruby"
20
+ spec.metadata["changelog_uri"] = "https://github.com/upriser/lipdub-ruby/blob/main/CHANGELOG.md"
21
+
22
+ # Specify which files should be added to the gem when it is released.
23
+ spec.files = Dir.chdir(__dir__) do
24
+ `git ls-files -z 2>/dev/null`.split("\x0").reject do |f|
25
+ (File.expand_path(f) == __FILE__) ||
26
+ f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile])
27
+ end
28
+ end
29
+ spec.bindir = "exe"
30
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
31
+ spec.require_paths = ["lib"]
32
+
33
+ # Runtime dependencies
34
+ spec.add_dependency "faraday", "~> 2.0"
35
+ spec.add_dependency "faraday-multipart", "~> 1.0"
36
+
37
+ # Development dependencies
38
+ spec.add_development_dependency "bundler", "~> 2.0"
39
+ spec.add_development_dependency "rake", "~> 13.0"
40
+ spec.add_development_dependency "rspec", "~> 3.0"
41
+ spec.add_development_dependency "webmock", "~> 3.0"
42
+ spec.add_development_dependency "vcr", "~> 6.0"
43
+ spec.add_development_dependency "rubocop", "~> 1.0"
44
+ spec.add_development_dependency "yard", "~> 0.9"
45
+ spec.add_development_dependency "bundler-audit", "~> 0.9"
46
+ end