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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/CHANGELOG.md +50 -0
- data/LICENSE.txt +21 -0
- data/README.md +955 -0
- data/Rakefile +41 -0
- data/lib/lipdub/client.rb +139 -0
- data/lib/lipdub/configuration.rb +17 -0
- data/lib/lipdub/errors.rb +25 -0
- data/lib/lipdub/resources/audios.rb +182 -0
- data/lib/lipdub/resources/base.rb +31 -0
- data/lib/lipdub/resources/projects.rb +53 -0
- data/lib/lipdub/resources/shots.rb +400 -0
- data/lib/lipdub/resources/videos.rb +163 -0
- data/lib/lipdub/version.rb +5 -0
- data/lib/lipdub.rb +33 -0
- data/lipdub.gemspec +46 -0
- metadata +205 -0
|
@@ -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
|
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
|