sonilo 0.6.0 → 0.8.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.
package/README.md CHANGED
@@ -11,7 +11,7 @@ npm install sonilo
11
11
 
12
12
  ## Authentication
13
13
 
14
- Create an API key in your [Sonilo dashboard](https://platform.sonilo.com/dashboard/api-keys),
14
+ Create an API key in your [Sonilo dashboard](https://platform.sonilo.com/dashboard/api-keys?utm_source=sonilo_js&utm_medium=readme&utm_campaign=sdk_quickstart),
15
15
  then give it to the client either as an environment variable (recommended) or
16
16
  inline:
17
17
 
@@ -107,6 +107,46 @@ if (result.ducked) {
107
107
  }
108
108
  ```
109
109
 
110
+ ### Variants (async)
111
+
112
+ `variantsNum` generates several distinct music variants in one request (1-10,
113
+ default 1) — each is its own creative direction, with its own title. It's
114
+ available on `textToMusic`, `videoToMusic`, `videoToVideoMusic`,
115
+ `videoToSound` and `videoToVideoSound`. Cost scales linearly with the count,
116
+ and **values above 1 are never covered by the free trial**.
117
+
118
+ On `textToMusic`/`videoToMusic`, `variantsNum` above 1 requires the async task
119
+ API — same as `preserveSpeech` above — so it implies `mode: "async"` if you
120
+ don't set `mode` yourself; `stream()`/`generate()` never send it, since they
121
+ always request a plain stream. `videoToVideoMusic`, `videoToSound` and
122
+ `videoToVideoSound` are already async-only, so no extra `mode` handling is
123
+ needed there.
124
+
125
+ ```ts
126
+ const task = await client.textToMusic.submit({
127
+ prompt: "warm lo-fi piano",
128
+ duration: 30,
129
+ variantsNum: 3,
130
+ });
131
+ const result = await client.tasks.wait<MusicTaskResult>(task.task_id);
132
+
133
+ // audio has one entry per variant; each entry may carry its own `title`.
134
+ for (const variant of result.audio ?? []) {
135
+ console.log(variant.title?.title, variant.url);
136
+ }
137
+ ```
138
+
139
+ `videoToVideoMusic` returns one video per variant in `videos[]`, with `video`
140
+ kept as a permanent alias for `videos[0]`. `videoToSound`/`videoToVideoSound`
141
+ return one entry per variant in `outputs[]`, each shaped like the top-level
142
+ result (`output_url`, `output_type`, `output_bytes`, `music`,
143
+ `music_processed?`, `sfx`) — the top-level fields remain permanent aliases for
144
+ `outputs[0]`. All of these arrays are present even at the default
145
+ `variantsNum` of 1, as a single-entry array; every other field is unchanged.
146
+
147
+ `GET /v1/tasks/{id}` (`tasks.get`/`tasks.wait`) echoes the request's
148
+ `variantsNum` back as `variants_num`, but only when it was above 1.
149
+
110
150
  ## Video to video
111
151
 
112
152
  Generate a soundtrack or sound effects and get back a **re-hosted video** with
@@ -175,6 +215,57 @@ Input videos may be at most 180 seconds long.
175
215
  Use `submit()` instead of `generate()` to get a `task_id` back immediately and
176
216
  poll it yourself with `client.tasks.wait<SoundResult>(taskId)`.
177
217
 
218
+ ## Dubbing
219
+
220
+ `client.dubbing.submit()` / `.generate()` dub a video into one or more target
221
+ languages in a single async call — one call, one task, one dubbed video per
222
+ language.
223
+
224
+ ```ts
225
+ import { SoniloClient } from "sonilo";
226
+ import type { DubbingResult } from "sonilo";
227
+
228
+ const client = new SoniloClient();
229
+
230
+ const task = await client.dubbing.submit({
231
+ videoUrl: "https://example.com/clip.mp4",
232
+ languages: ["es", "fr"],
233
+ });
234
+ const result = await client.tasks.wait<DubbingResult>(task.task_id);
235
+ for (const [language, url] of Object.entries(result.outputs ?? {})) {
236
+ console.log(language, url);
237
+ }
238
+ ```
239
+
240
+ `generate()` wraps submit + poll, same as the other async endpoints, and
241
+ accepts a `{ timeout }` option to override the default 10-minute wait. The
242
+ dubbing pipeline can take much longer than that, especially with several
243
+ languages in one call, so pass a longer timeout for anything but the shortest
244
+ clips. 7,200,000 ms matches the backend's own ceiling for a dubbing job and is
245
+ what the CLI defaults to. For long jobs you can also use `submit()` plus your
246
+ own `client.tasks.wait()`, as above:
247
+
248
+ ```ts
249
+ const result = await client.dubbing.generate(
250
+ { videoUrl: "https://example.com/clip.mp4", languages: ["es", "fr"] },
251
+ { timeout: 7_200_000 }, // 2 hours, the backend's own ceiling
252
+ );
253
+ ```
254
+
255
+ Params: exactly one of `video` / `videoUrl` (`videoUrl` must be **https** —
256
+ the dubbing pipeline fetches the source itself and rejects plain http). The
257
+ optional `languages` array defaults to `["zh_cn", "es", "fr"]`; supported
258
+ codes are `en, zh_cn, ja, ko, pt, es, de, fr, it, ru`.
259
+
260
+ Dubbing is async-only, and the source video may be at most 180 seconds long.
261
+ You are billed per language. Dubbing has **no free trial allowance** — unlike
262
+ every other endpoint, every call bills from the first one (see
263
+ [Free trial](#free-trial)).
264
+
265
+ The result is a `DubbingResult`, whose `outputs` is a map of language code to
266
+ dubbed `.mp4` URL — not the `audio`/`video`/`output_url` shape the other
267
+ endpoints use.
268
+
178
269
  ## Configuration
179
270
 
180
271
  ```ts
@@ -258,15 +349,25 @@ are presigned and expire; download promptly or re-fetch via `tasks.get`.
258
349
 
259
350
  ## Free trial
260
351
 
261
- Accounts created through self-serve signup start with free runs on every
262
- endpoint — no card required:
352
+ Accounts created through self-serve signup start with free runs on most
353
+ endpoints — no card required:
263
354
 
264
355
  | Free runs | Endpoints |
265
356
  | --- | --- |
266
357
  | 2 each | text-to-music, text-to-sfx, audio-ducking |
267
358
  | 1 each | video-to-music, video-to-sfx, video-to-video-music, video-to-video-sfx, video-to-sound, video-to-video-sound |
359
+ | 0 | dubbing |
268
360
 
269
361
  Once an endpoint's free runs are used up, calls to it bill at the normal rate.
362
+ **Dubbing has no free trial allowance at all** — it bills every call from the
363
+ first one. This is deliberate: dubbing charges `video_duration ×
364
+ number_of_languages`, so a single "free" run could easily cost more than the
365
+ free allowance on every other endpoint combined.
366
+
367
+ The table above is the current default. Read the live numbers from
368
+ `account.services()` rather than hard-coding them — see
369
+ [Account](#account) below, and [Errors](#errors) for what a spent trial
370
+ looks like at the call site.
270
371
 
271
372
  ## Account
272
373
 
@@ -275,10 +376,27 @@ const services = await sonilo.account.services();
275
376
  const usage = await sonilo.account.usage({ days: 7 });
276
377
  ```
277
378
 
379
+ `services.trial` reports the free-trial allowance per service, so an
380
+ integration can degrade gracefully *before* a call fails:
381
+
382
+ ```ts
383
+ const { trial } = await sonilo.account.services();
384
+ const quota = trial?.text_to_music;
385
+ if (quota && quota.remaining === 0) {
386
+ // Prompt for a payment method instead of firing a call that will 402.
387
+ console.log(`Free trial spent (${quota.used}/${quota.granted}).`);
388
+ }
389
+ ```
390
+
391
+ `trial` is present only for self-serve accounts, so always treat it as
392
+ optional; a service missing from the map has no trial allowance rather than
393
+ an unlimited one.
394
+
278
395
  ## Errors
279
396
 
280
397
  All errors extend `SoniloError`: `AuthenticationError` (401),
281
- `PaymentRequiredError` (402), `RateLimitError` (429, `.retryAfter`),
398
+ `PaymentRequiredError` (402), `TrialExhaustedError` (402, a subclass of
399
+ `PaymentRequiredError`), `RateLimitError` (429, `.retryAfter`),
282
400
  `BadRequestError` (400/413/422, `.detail`), `APIError` (anything else),
283
401
  `GenerationError` for failures mid-stream, `TaskFailedError` (`.code`,
284
402
  `.taskId`, `.refunded`) for a failed SFX task, `TaskTimeoutError`
@@ -292,3 +410,29 @@ Every `APIError` also carries `.status`, `.body` (the parsed response),
292
410
  `.code` (the API's error code, e.g. `"rate_limit_exceeded"`), and `.errors`
293
411
  (the validation detail array on a 422), in addition to any subclass-specific
294
412
  properties above.
413
+
414
+ ### The three 402s
415
+
416
+ A `402` is not one condition. Branch on the class (or equivalently on
417
+ `.code`), never on the message text:
418
+
419
+ ```ts
420
+ try {
421
+ await sonilo.textToMusic.generate({ prompt: "lofi", duration: 30 });
422
+ } catch (err) {
423
+ if (err instanceof TrialExhaustedError) {
424
+ // code: "trial_exhausted" — the free trial for this service is spent and
425
+ // the account has never been funded. Prompt for a payment method; a retry
426
+ // can never succeed.
427
+ } else if (err instanceof PaymentRequiredError) {
428
+ // code: "insufficient_balance" — a funded wallet ran dry. Add balance and
429
+ // retry the same request.
430
+ // code: "payment_required" — anything else, e.g. a suspended account.
431
+ }
432
+ }
433
+ ```
434
+
435
+ `TrialExhaustedError` extends `PaymentRequiredError`, so an existing
436
+ `catch (err) { if (err instanceof PaymentRequiredError) ... }` keeps
437
+ catching every 402 — order the checks most-specific-first if you want to
438
+ tell them apart.
package/dist/index.cjs CHANGED
@@ -32,6 +32,7 @@ __export(index_exports, {
32
32
  SoniloError: () => SoniloError,
33
33
  TaskFailedError: () => TaskFailedError,
34
34
  TaskTimeoutError: () => TaskTimeoutError,
35
+ TrialExhaustedError: () => TrialExhaustedError,
35
36
  VERSION: () => VERSION,
36
37
  download: () => download,
37
38
  isAudioChunkEvent: () => isAudioChunkEvent,
@@ -60,6 +61,8 @@ var AuthenticationError = class extends APIError {
60
61
  };
61
62
  var PaymentRequiredError = class extends APIError {
62
63
  };
64
+ var TrialExhaustedError = class extends PaymentRequiredError {
65
+ };
63
66
  var BadRequestError = class extends APIError {
64
67
  get detail() {
65
68
  const body = this.body;
@@ -129,8 +132,13 @@ async function errorFromResponse(res) {
129
132
  switch (res.status) {
130
133
  case 401:
131
134
  return new AuthenticationError(message, res.status, body);
132
- case 402:
135
+ case 402: {
136
+ const code = body?.code;
137
+ if (code === "trial_exhausted") {
138
+ return new TrialExhaustedError(message, res.status, body);
139
+ }
133
140
  return new PaymentRequiredError(message, res.status, body);
141
+ }
134
142
  case 429: {
135
143
  const ra = res.headers.get("retry-after");
136
144
  const retryAfter = ra !== null && ra !== "" && !Number.isNaN(Number(ra)) ? Number(ra) : void 0;
@@ -339,7 +347,8 @@ var TextToMusic = class {
339
347
  /**
340
348
  * Submit an async text-to-music task; poll with
341
349
  * `client.tasks.wait<MusicTaskResult>(task.task_id)`. Required for
342
- * `outputFormat: "wav"`. `stream()`/`generate()` remain the streaming path.
350
+ * `outputFormat: "wav"` and `variantsNum` above 1. `stream()`/`generate()`
351
+ * remain the streaming path.
343
352
  */
344
353
  async submit(params) {
345
354
  const mode = params.mode ?? "async";
@@ -356,6 +365,9 @@ var TextToMusic = class {
356
365
  if (params.outputFormat !== void 0) {
357
366
  form.set("output_format", params.outputFormat);
358
367
  }
368
+ if (params.variantsNum !== void 0) {
369
+ form.set("variants_num", String(params.variantsNum));
370
+ }
359
371
  const res = await this.client.request("/v1/text-to-music", {
360
372
  method: "POST",
361
373
  body: form
@@ -436,19 +448,20 @@ var VideoToMusic = class {
436
448
  /**
437
449
  * Submit an async video-to-music task; poll its result with
438
450
  * `client.tasks.wait<MusicTaskResult>(task.task_id)`. Required for
439
- * `isolateVocals` the backend rejects vocal isolation on the plain
440
- * stream, and it only ever runs in async mode.
451
+ * `isolateVocals`/`preserveSpeech`, `outputFormat: "wav"`, and
452
+ * `variantsNum` above 1 the backend rejects all of these on the plain
453
+ * stream, and they only ever run in async mode.
441
454
  */
442
455
  async submit(params) {
443
456
  if (params.video === void 0 === (params.videoUrl === void 0)) {
444
457
  throw new SoniloError("Provide exactly one of video or videoUrl");
445
458
  }
446
459
  let mode = params.mode;
447
- const needsAsync = params.isolateVocals || params.preserveSpeech || params.ducking !== void 0 || params.outputFormat === "wav";
460
+ const needsAsync = params.isolateVocals || params.preserveSpeech || params.ducking !== void 0 || params.outputFormat === "wav" || params.variantsNum !== void 0 && params.variantsNum > 1;
448
461
  if (mode === void 0) mode = "async";
449
462
  if (needsAsync && mode !== "async") {
450
463
  throw new SoniloError(
451
- 'isolateVocals/preserveSpeech/ducking/outputFormat "wav" require mode: "async"'
464
+ 'isolateVocals/preserveSpeech/ducking/outputFormat "wav"/variantsNum > 1 require mode: "async"'
452
465
  );
453
466
  }
454
467
  const form = new FormData();
@@ -475,6 +488,9 @@ var VideoToMusic = class {
475
488
  if (params.ducking !== void 0) {
476
489
  form.set("ducking", String(params.ducking));
477
490
  }
491
+ if (params.variantsNum !== void 0) {
492
+ form.set("variants_num", String(params.variantsNum));
493
+ }
478
494
  const res = await this.client.request("/v1/video-to-music", {
479
495
  method: "POST",
480
496
  body: form
@@ -561,6 +577,9 @@ var VideoToVideoMusic = class {
561
577
  if (params.isolateVocals !== void 0) {
562
578
  form.set("isolate_vocals", String(params.isolateVocals));
563
579
  }
580
+ if (params.variantsNum !== void 0) {
581
+ form.set("variants_num", String(params.variantsNum));
582
+ }
564
583
  const res = await this.client.request("/v1/video-to-video-music", {
565
584
  method: "POST",
566
585
  body: form
@@ -626,6 +645,9 @@ async function buildSoundForm(params) {
626
645
  form.set("preserve_speech", String(params.preserveSpeech));
627
646
  }
628
647
  if (params.ducking !== void 0) form.set("ducking", String(params.ducking));
648
+ if (params.variantsNum !== void 0) {
649
+ form.set("variants_num", String(params.variantsNum));
650
+ }
629
651
  return form;
630
652
  }
631
653
 
@@ -665,8 +687,48 @@ var VideoToVideoSound = class {
665
687
  }
666
688
  };
667
689
 
690
+ // src/resources/dubbing.ts
691
+ async function buildDubbingForm(params) {
692
+ if (params.video === void 0 === (params.videoUrl === void 0)) {
693
+ throw new SoniloError("Provide exactly one of video or videoUrl");
694
+ }
695
+ const form = new FormData();
696
+ if (params.video !== void 0) {
697
+ const { blob, filename } = await toUploadBlob(params.video);
698
+ form.set("video", blob, filename);
699
+ } else {
700
+ const url = params.videoUrl;
701
+ if (!url.toLowerCase().startsWith("https://")) {
702
+ throw new SoniloError(
703
+ "videoUrl must use https \u2014 the dubbing pipeline requires an https URL"
704
+ );
705
+ }
706
+ form.set("video_url", url);
707
+ }
708
+ if (params.languages !== void 0) {
709
+ form.set("languages", JSON.stringify(params.languages));
710
+ }
711
+ return form;
712
+ }
713
+ var Dubbing = class {
714
+ constructor(client) {
715
+ this.client = client;
716
+ }
717
+ async submit(params) {
718
+ const res = await this.client.request("/v1/dubbing", {
719
+ method: "POST",
720
+ body: await buildDubbingForm(params)
721
+ });
722
+ return await res.json();
723
+ }
724
+ async generate(params, opts) {
725
+ const task = await this.submit(params);
726
+ return this.client.tasks.wait(task.task_id, opts);
727
+ }
728
+ };
729
+
668
730
  // src/version.ts
669
- var VERSION = "0.6.0";
731
+ var VERSION = "0.8.0";
670
732
 
671
733
  // src/client.ts
672
734
  var DEFAULT_BASE_URL = "https://api.sonilo.com";
@@ -697,6 +759,7 @@ var SoniloClient = class {
697
759
  this.videoToVideoSfx = new VideoToVideoSfx(this);
698
760
  this.videoToSound = new VideoToSound(this);
699
761
  this.videoToVideoSound = new VideoToVideoSound(this);
762
+ this.dubbing = new Dubbing(this);
700
763
  }
701
764
  /**
702
765
  * Perform an authenticated request; throws a typed error on non-2xx.
@@ -769,6 +832,7 @@ function isErrorEvent(event) {
769
832
  SoniloError,
770
833
  TaskFailedError,
771
834
  TaskTimeoutError,
835
+ TrialExhaustedError,
772
836
  VERSION,
773
837
  download,
774
838
  isAudioChunkEvent,