| Language | Source | Install |
|---|---|---|
| TypeScript, on Node 22+, Bun and Deno | braskai/rask-sdk-typescript | From npm: @braskai/api |
| Python 3.11+ | braskai/rask-sdk-python | From PyPI: rask-sdk |
| Go 1.25+ | braskai/rask-sdk-go | With go get: github.com/braskai/rask-sdk-go/v3 |
| Java 11+ | braskai/rask-sdk-java | From Maven Central: ai.rask:rask-sdk |
| PHP 8.2+ | braskai/rask-sdk-php | From Packagist: braskai/rask-sdk |
Install
npm install @braskai/api
pip install rask-sdk
go get github.com/braskai/rask-sdk-go/v3
<dependency>
<groupId>ai.rask</groupId>
<artifactId>rask-sdk</artifactId>
<version>3.0.0-alpha.0</version>
</dependency>
composer require "braskai/rask-sdk:^3.0@alpha"
- TypeScript. The same package runs on Bun and Deno:
bun add @braskai/apiordeno add npm:@braskai/api. - Python. With uv:
uv add rask-sdk. - Java. With Gradle:
implementation("ai.rask:rask-sdk:3.0.0-alpha.0"). Maven and Gradle take an exact version, and Maven Central shows the newest one. - PHP. Composer installs a pre-release only when the version constraint allows it, so the command names the alpha explicitly. Without that, Composer answers that it finds no stable version.
export RASK_API_KEY="paste your key here"
Make a client
Every example in these pages has a tab for each language, and cURL. A tab shows the call and nothing around it: it takes a client made like this, which reads the key fromRASK_API_KEY.
import { Rask } from '@braskai/api';
const rask = new Rask();
from rask_sdk import Rask
client = Rask()
import (
"context"
"github.com/braskai/rask-sdk-go/v3"
"github.com/braskai/rask-sdk-go/v3/api"
)
ctx := context.Background()
client, err := rask.New()
import ai.rask.sdk.Rask;
Rask rask = Rask.builder().build();
use Rask\Sdk\Rask;
$rask = new Rask();
Upload, wait and handle errors
The tabs on the other pages show one call each. Three things take more than one call over plain HTTP, and every SDK does them for you: uploading a file, waiting for a job, and telling one error from another. Each tab here is a whole program that does all three: it uploads a video, dubs it into Spanish and downloads the result.// Upload a video, dub it into Spanish and download the result: node dub.ts talk.mp4
// What it has done is saved in talk.mp4.dub.json. If it stops, run it again: it sends the same keys, and the API replays
// what already ran, so no project or dub is created or charged twice. Delete the file to dub the video anew.
import { randomUUID } from 'node:crypto';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { writeFile } from 'node:fs/promises';
import {
ConflictError,
InsufficientBalanceError,
JobCanceledError,
JobFailedError,
Rask,
RaskConnectionError,
RaskError,
WaitTimeoutError,
} from '@braskai/api';
const path = process.argv[2] ?? 'talk.mp4';
const statePath = `${path}.dub.json`;
const state: Record<string, string | number> = existsSync(statePath) ? JSON.parse(readFileSync(statePath, 'utf8')) : {};
// The value saved under this name, or a new one, saved before the program goes on.
async function once<T extends string | number>(name: string, make: () => T | Promise<T>): Promise<T> {
if (!(name in state)) {
state[name] = await make();
writeFileSync(statePath, JSON.stringify(state, null, 2));
}
return state[name] as T;
}
function stop(message: string): never {
console.error(message);
process.exit(1);
}
try {
const rask = new Rask(); // The key from RASK_API_KEY, and the base URL from RASK_BASE_URL when it is set.
const mediaId = await once('media_id', async () => {
// One call uploads the file: createUpload, the PUT to storage and completeUpload. It answers with the import job.
const { data: importJob } = await rask.uploads.uploadFile(path);
// One call waits for a job, with the API's long-poll. A job that failed is thrown, not returned.
const { data: imported } = await rask.jobs.wait(importJob.id);
if (imported.result?.type !== 'media_import') stop(`Job ${imported.id} has no media.`);
return imported.result.media_id;
});
const order = { media_id: mediaId, target_language: 'es-es' };
// The minutes shown once are the most this program spends: they are the cap of every paid request below.
const maxMinutes = await once('max_minutes', async () => {
const { data: estimate } = await rask.projects.estimateProject({ body: order });
if (!estimate.eligible) stop(`Cannot start: ${estimate.reasons.map((reason) => reason.code).join(', ')}`);
console.log(`The dub costs ${estimate.total_minutes} minutes.`);
return estimate.total_minutes;
});
// The SDK makes an Idempotency-Key by itself, but a new one on every run. This key is saved before it is sent.
const { data: created } = await rask.projects.createProject({
body: { ...order, expected_cost_minutes: maxMinutes },
idempotencyKey: await once('create_key', () => randomUUID()),
});
const { data: processed } = await rask.jobs.wait(created.id);
if (processed.result?.type !== 'project_processing') stop(`Job ${processed.id} has no project.`);
const projectId = processed.result.project_id;
// That job ends when translation ends. The dub is a job of its own.
const autoDub = processed.result.auto_dub;
if (autoDub.state === 'dispatched' && autoDub.dub_job_id) {
await rask.jobs.wait(autoDub.dub_job_id);
} else {
// The dub was skipped (skipped_reason says why) or not requested, and nothing was charged. Dub within the same cap.
const { data: estimate } = await rask.dubbing.estimateDub({ path: { project_id: projectId }, body: {} });
if (estimate.total_minutes > maxMinutes) {
stop(
`The dub now costs ${estimate.total_minutes} minutes, more than the ${maxMinutes} you accepted, and nothing was ` +
`charged. Project ${projectId} keeps its translation: dub it once you accept the new price.`,
);
}
const { data: dub } = await rask.dubbing.createDub({
path: { project_id: projectId },
body: { expected_cost_minutes: maxMinutes },
idempotencyKey: await once('dub_key', () => randomUUID()),
});
await rask.jobs.wait(dub.id);
}
// The link is presigned: the download takes no key, and it works for 1 hour.
const { data: outputs } = await rask.exports.listProjectOutputs({ path: { project_id: projectId } });
const video = outputs.data.find((output) => output.kind === 'dubbed_video' && output.url);
if (!video?.url) stop(`Project ${projectId} has no dubbed video to download.`);
const download = await fetch(video.url);
if (!download.ok) stop(`The download answered ${download.status}. Run this again for a fresh link.`);
await writeFile('dubbed.mp4', new Uint8Array(await download.arrayBuffer()));
console.log('Saved dubbed.mp4');
} catch (error) {
// Check the subclasses before RaskError, the base of them all.
if (error instanceof InsufficientBalanceError) {
// creditType says which balance: minutes, or the project credits that createProject takes.
stop(`${error.detail} Add to the balance in the web app, then run this again.`);
} else if (error instanceof ConflictError && error.code === 'cost_changed') {
stop(
`The price is now ${error.problem?.current_cost_minutes} minutes, more than the ${error.problem?.expected_cost_minutes} ` +
`you accepted, and nothing was charged. Delete ${statePath} to start again at the new price.`,
);
} else if (error instanceof JobFailedError) {
stop(`Job ${error.job.id} failed: ${error.code}: ${error.detail}`);
} else if (error instanceof JobCanceledError) {
stop(`Job ${error.job.id} was canceled.`);
} else if (error instanceof WaitTimeoutError) {
stop(`Job ${error.jobId} is still running after ${error.timeoutSeconds} s. Run this again to keep waiting.`);
} else if (error instanceof RaskConnectionError) {
stop(`No answer from the API: ${error.message}. Run this again: it sends the same keys.`);
} else if (error instanceof RaskError) {
stop(error.message);
}
throw error;
}
"""Upload a video, dub it into Spanish and download the result: python dub.py talk.mp4
What it has done is saved in talk.mp4.dub.json. If it stops, run it again: it sends the same keys, and the API replays what
already ran, so no project or dub is created or charged twice. Delete the file to dub the video anew.
"""
import json
import sys
import uuid
from collections.abc import Callable
from pathlib import Path
from typing import TypeVar, cast
import httpx
from rask_sdk import (
ConflictError,
InsufficientBalanceError,
JobCanceledError,
JobFailedError,
Rask,
RaskConnectionError,
RaskError,
WaitTimeoutError,
)
from rask_sdk.models import MediaImportJobResult, ProjectProcessingJobResult
path = sys.argv[1] if len(sys.argv) > 1 else "talk.mp4"
state_file = Path(path + ".dub.json")
state: dict[str, str | int] = json.loads(state_file.read_text()) if state_file.exists() else {}
Value = TypeVar("Value", str, int)
def once(name: str, make: Callable[[], Value]) -> Value:
"""The value saved under this name, or a new one, saved before the program goes on."""
if name not in state:
state[name] = make()
state_file.write_text(json.dumps(state, indent=2))
return cast(Value, state[name])
def upload(client: Rask) -> str:
# One call uploads the file: createUpload, the PUT to storage and completeUpload. It answers with the import job.
import_job = client.uploads.upload_file(path)
# One call waits for a job, with the API's long-poll. A job that failed is raised, not returned.
imported = client.jobs.wait(import_job.id)
if not isinstance(imported.result, MediaImportJobResult):
sys.exit(f"Job {imported.id} has no media.")
return imported.result.media_id
def accept_price(client: Rask, media_id: str) -> int:
estimate = client.projects.estimate_project(media_id=media_id, target_language="es-es")
if not estimate.eligible:
sys.exit(f"Cannot start: {', '.join(reason.code for reason in estimate.reasons)}")
print(f"The dub costs {estimate.total_minutes} minutes.")
return estimate.total_minutes
def dub(client: Rask) -> None:
media_id = once("media_id", lambda: upload(client))
# The minutes shown once are the most this program spends: they are the cap of every paid request below.
max_minutes = once("max_minutes", lambda: accept_price(client, media_id))
# The SDK makes an Idempotency-Key by itself, but a new one on every run. This key is saved before it is sent.
created = client.projects.create_project(
media_id=media_id,
target_language="es-es",
expected_cost_minutes=max_minutes,
idempotency_key=once("create_key", lambda: str(uuid.uuid4())),
)
processed = client.jobs.wait(created.id)
if not isinstance(processed.result, ProjectProcessingJobResult):
sys.exit(f"Job {processed.id} has no project.")
project_id = processed.result.project_id
# That job ends when translation ends. The dub is a job of its own.
auto_dub = processed.result.auto_dub
if auto_dub.state == "dispatched" and auto_dub.dub_job_id:
client.jobs.wait(auto_dub.dub_job_id)
else:
# The dub was skipped (skipped_reason says why) or not requested, and nothing was charged. Dub within the same cap.
estimate = client.dubbing.estimate_dub(project_id)
if estimate.total_minutes > max_minutes:
sys.exit(
f"The dub now costs {estimate.total_minutes} minutes, more than the {max_minutes} you accepted, and nothing "
f"was charged. Project {project_id} keeps its translation: dub it once you accept the new price."
)
dub_job = client.dubbing.create_dub(
project_id, expected_cost_minutes=max_minutes, idempotency_key=once("dub_key", lambda: str(uuid.uuid4()))
)
client.jobs.wait(dub_job.id)
# The link is presigned: the download takes no key, and it works for 1 hour.
outputs = client.exports.list_project_outputs(project_id).data
url = next((output.url for output in outputs if output.kind == "dubbed_video" and output.url), None)
if url is None:
sys.exit(f"Project {project_id} has no dubbed video to download.")
with httpx.stream("GET", url, timeout=60) as response:
if response.status_code != 200:
sys.exit(f"The download answered {response.status_code}. Run this again for a fresh link.")
with open("dubbed.mp4", "wb") as file:
for chunk in response.iter_bytes():
file.write(chunk)
print("Saved dubbed.mp4")
try:
dub(Rask()) # The key from RASK_API_KEY, and the base URL from RASK_BASE_URL when it is set.
except RaskError as error:
# Check the subclasses before RaskError, the base of them all.
if isinstance(error, InsufficientBalanceError):
# credit_type says which balance: minutes, or the project credits that create_project takes.
sys.exit(f"{error.detail} Add to the balance in the web app, then run this again.")
if isinstance(error, ConflictError) and error.code == "cost_changed":
sys.exit(
f"The price is now {error.current_cost_minutes} minutes, more than the {error.expected_cost_minutes} you "
f"accepted, and nothing was charged. Delete {state_file} to start again at the new price."
)
if isinstance(error, JobFailedError):
sys.exit(f"Job {error.job.id} failed: {error.code}: {error.detail}")
if isinstance(error, JobCanceledError):
sys.exit(f"Job {error.job.id} was canceled.")
if isinstance(error, WaitTimeoutError):
sys.exit(f"Job {error.job_id} is still running. Run this again to keep waiting.")
if isinstance(error, RaskConnectionError):
sys.exit(f"No answer from the API: {error}. Run this again: it sends the same keys.")
sys.exit(str(error))
// Command dub uploads a video, dubs it into Spanish and downloads the result: go run . talk.mp4
//
// What it has done is saved in talk.mp4.dub.json. If it stops, run it again: it sends the same keys, and the API replays
// what already ran, so no project or dub is created or charged twice. Delete the file to dub the video anew.
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"strconv"
"strings"
"github.com/braskai/rask-sdk-go/v3"
"github.com/braskai/rask-sdk-go/v3/api"
"github.com/google/uuid"
)
func main() {
path := "talk.mp4"
if len(os.Args) > 1 {
path = os.Args[1]
}
client, err := rask.New() // The key from RASK_API_KEY, and the base URL from RASK_BASE_URL when it is set.
if err == nil {
err = dub(context.Background(), client, path)
}
if err != nil {
log.Fatal(explain(err, path))
}
fmt.Println("Saved dubbed.mp4")
}
func dub(ctx context.Context, client *rask.Client, path string) error {
saved, err := load(path + ".dub.json")
if err != nil {
return err
}
mediaID, err := saved.once("media_id", func() (string, error) {
// One call uploads the file: createUpload, the PUT to storage and completeUpload. It answers with the import job.
importJob, err := client.Uploads.UploadFile(ctx, rask.UploadFileParams{Path: path})
if err != nil {
return "", err
}
// One call waits for a job, with the API's long-poll. A job that failed is an error, not a result.
imported, err := client.Jobs.Wait(ctx, importJob.Data.Id)
if err != nil {
return "", err
}
result, err := imported.Data.Result.Get()
if err != nil {
return "", fmt.Errorf("job %s has no media", imported.Data.Id)
}
media, err := result.AsMediaImportJobResult()
if err != nil {
return "", err
}
return media.MediaId, nil
})
if err != nil {
return err
}
// The minutes shown once are the most this program spends: they are the cap of every paid request below.
accepted, err := saved.once("max_minutes", func() (string, error) {
estimate, err := client.Projects.EstimateProject(ctx, api.EstimateProjectRequest{MediaId: mediaID, TargetLanguage: "es-es"})
if err != nil {
return "", err
}
if !estimate.Data.Eligible {
var codes []string
for _, reason := range estimate.Data.Reasons {
codes = append(codes, reason.Code)
}
return "", fmt.Errorf("cannot start: %s", strings.Join(codes, ", "))
}
fmt.Printf("The dub costs %d minutes.\n", estimate.Data.TotalMinutes)
return strconv.Itoa(int(estimate.Data.TotalMinutes)), nil
})
if err != nil {
return err
}
maxMinutes, err := strconv.Atoi(accepted)
if err != nil {
return err
}
// The SDK makes an Idempotency-Key by itself, but a new one on every run. This key is saved before it is sent.
createKey, err := saved.once("create_key", func() (string, error) { return uuid.NewString(), nil })
if err != nil {
return err
}
created, err := client.Projects.CreateProject(ctx, api.CreateProjectRequest{
MediaId: mediaID,
TargetLanguage: "es-es",
ExpectedCostMinutes: rask.Ptr(int32(maxMinutes)),
}, rask.WithIdempotencyKey(createKey))
if err != nil {
return err
}
processed, err := client.Jobs.Wait(ctx, created.Data.Id)
if err != nil {
return err
}
result, err := processed.Data.Result.Get()
if err != nil {
return fmt.Errorf("job %s has no project", processed.Data.Id)
}
processing, err := result.AsProjectProcessingJobResult()
if err != nil {
return err
}
projectID := processing.ProjectId
// That job ends when translation ends. The dub is a job of its own.
dubJobID, err := processing.AutoDub.DubJobId.Get()
if processing.AutoDub.State == api.AutoDubResultStateDispatched && err == nil {
if _, err := client.Jobs.Wait(ctx, dubJobID); err != nil {
return err
}
} else {
// The dub was skipped (skipped_reason says why) or not requested, and nothing was charged. Dub within the same cap.
estimate, err := client.Dubbing.EstimateDub(ctx, projectID, nil)
if err != nil {
return err
}
if int(estimate.Data.TotalMinutes) > maxMinutes {
return fmt.Errorf("the dub now costs %d minutes, more than the %d you accepted, and nothing was charged. "+
"Project %s keeps its translation: dub it once you accept the new price", estimate.Data.TotalMinutes, maxMinutes, projectID)
}
dubKey, err := saved.once("dub_key", func() (string, error) { return uuid.NewString(), nil })
if err != nil {
return err
}
dubJob, err := client.Dubbing.CreateDub(ctx, projectID, api.CreateDubRequest{
ExpectedCostMinutes: int32(maxMinutes),
}, rask.WithIdempotencyKey(dubKey))
if err != nil {
return err
}
if _, err := client.Jobs.Wait(ctx, dubJob.Data.Id); err != nil {
return err
}
}
// The link is presigned: the download takes no key, and it works for 1 hour.
outputs, err := client.Exports.ListProjectOutputs(ctx, projectID)
if err != nil {
return err
}
for _, output := range outputs.Data.Data {
if url, err := output.Url.Get(); err == nil && output.Kind == api.OutputKindDubbedVideo {
return download(ctx, url, "dubbed.mp4")
}
}
return fmt.Errorf("project %s has no dubbed video to download", projectID)
}
// explain says what went wrong. Each API error is a *rask.RaskError too, so the specific types are matched first.
func explain(err error, path string) string {
var noBalance *rask.InsufficientBalanceError
var conflict *rask.ConflictError
var failed *rask.JobFailedError
var canceled *rask.JobCanceledError
var timedOut *rask.WaitTimeoutError
var noAnswer *rask.ConnectionError
switch {
case errors.As(err, &noBalance):
// CreditType says which balance: minutes, or the project credits that CreateProject takes.
return noBalance.Detail + " Add to the balance in the web app, then run this again."
case errors.As(err, &conflict) && conflict.Code == "cost_changed":
return fmt.Sprintf("The price is now %s minutes, more than the %s you accepted, and nothing was charged. "+
"Delete %s.dub.json to start again at the new price.",
minutes(conflict.Problem.CurrentCostMinutes), minutes(conflict.Problem.ExpectedCostMinutes), path)
case errors.As(err, &failed):
return fmt.Sprintf("Job %s failed: %s: %s", failed.JobID, failed.Code, failed.Detail)
case errors.As(err, &canceled):
return fmt.Sprintf("Job %s was canceled.", canceled.JobID)
case errors.As(err, &timedOut):
return fmt.Sprintf("Job %s is still running after %s. Run this again to keep waiting.", timedOut.JobID, timedOut.Timeout)
case errors.As(err, &noAnswer):
return fmt.Sprintf("No answer from the API: %v. Run this again: it sends the same keys.", noAnswer)
}
// Any other *rask.RaskError, and everything else, says what it is.
return err.Error()
}
func minutes(value *int32) string {
if value == nil {
return "?"
}
return strconv.Itoa(int(*value))
}
func download(ctx context.Context, url, path string) error {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
response, err := http.DefaultClient.Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return fmt.Errorf("the download answered %d. Run this again for a fresh link", response.StatusCode)
}
file, err := os.Create(path)
if err != nil {
return err
}
if _, err := io.Copy(file, response.Body); err != nil {
file.Close()
return err
}
return file.Close()
}
// state is what the program has done, saved in a file so that a second run goes on from where the first stopped.
type state struct {
path string
values map[string]string
}
func load(path string) (*state, error) {
saved := &state{path: path, values: map[string]string{}}
data, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return saved, nil
}
if err != nil {
return nil, err
}
return saved, json.Unmarshal(data, &saved.values)
}
// once returns the value saved under this name, or a new one, saved before the program goes on.
func (s *state) once(name string, create func() (string, error)) (string, error) {
if value, ok := s.values[name]; ok {
return value, nil
}
value, err := create()
if err != nil {
return "", err
}
s.values[name] = value
data, err := json.MarshalIndent(s.values, "", " ")
if err != nil {
return "", err
}
return value, os.WriteFile(s.path, data, 0o600)
}
import ai.rask.sdk.Rask;
import ai.rask.sdk.RequestOptions;
import ai.rask.sdk.errors.ConflictError;
import ai.rask.sdk.errors.InsufficientBalanceError;
import ai.rask.sdk.errors.JobCanceledError;
import ai.rask.sdk.errors.JobFailedError;
import ai.rask.sdk.errors.RaskConnectionError;
import ai.rask.sdk.errors.RaskError;
import ai.rask.sdk.errors.WaitTimeoutError;
import ai.rask.sdk.model.AutoDubResultState;
import ai.rask.sdk.model.CreateDubRequest;
import ai.rask.sdk.model.CreateProjectRequest;
import ai.rask.sdk.model.Estimate;
import ai.rask.sdk.model.EstimateProjectRequest;
import ai.rask.sdk.model.EstimateReason;
import ai.rask.sdk.model.Job;
import ai.rask.sdk.model.MediaImportJobResult;
import ai.rask.sdk.model.Output;
import ai.rask.sdk.model.OutputKind;
import ai.rask.sdk.model.ProjectProcessingAutoDubResult;
import ai.rask.sdk.model.ProjectProcessingJobResult;
import java.io.IOException;
import java.io.InputStream;
import java.io.Reader;
import java.io.UncheckedIOException;
import java.io.Writer;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.Properties;
import java.util.UUID;
import java.util.function.Supplier;
import java.util.stream.Collectors;
/**
* Uploads a video, dubs it into Spanish and downloads the result: java -cp rask-sdk-3.0.0-alpha.0.jar Dub.java talk.mp4
*
* <p>What it has done is saved in talk.mp4.dub.properties. If it stops, run it again: it sends the same keys, and the API
* replays what already ran, so no project or dub is created or charged twice. Delete the file to dub the video anew.
*/
public final class Dub {
private static Path statePath;
private static final Properties STATE = new Properties();
public static void main(String[] args) throws IOException, InterruptedException {
String path = args.length > 0 ? args[0] : "talk.mp4";
statePath = Path.of(path + ".dub.properties");
if (Files.exists(statePath)) {
try (Reader reader = Files.newBufferedReader(statePath)) {
STATE.load(reader);
}
}
try {
dub(Rask.builder().build(), path); // The key from RASK_API_KEY, and the base URL from RASK_BASE_URL when it is set.
} catch (InsufficientBalanceError error) {
// creditType() says which balance: minutes, or the project credits that createProject takes.
stop(error.detail() + " Add to the balance in the web app, then run this again.");
} catch (ConflictError error) {
if ("cost_changed".equals(error.code())) {
stop("The price is now " + error.problem().currentCostMinutes() + " minutes, more than the "
+ error.problem().expectedCostMinutes() + " you accepted, and nothing was charged. Delete "
+ statePath + " to start again at the new price.");
}
stop(error.getMessage());
} catch (JobFailedError error) {
stop("Job " + error.job().id() + " failed: " + error.code() + ": " + error.detail());
} catch (JobCanceledError error) {
stop("Job " + error.job().id() + " was canceled.");
} catch (WaitTimeoutError error) {
stop("Job " + error.jobId() + " is still running. Run this again to keep waiting.");
} catch (RaskConnectionError error) {
stop("No answer from the API: " + error.getMessage() + ". Run this again: it sends the same keys.");
} catch (RaskError error) {
// The base of them all, so it comes last.
stop(error.getMessage());
}
}
private static void dub(Rask rask, String path) throws IOException, InterruptedException {
String mediaId = once("media_id", () -> {
// One call uploads the file: createUpload, the PUT to storage and completeUpload. It answers with the import job.
Job importJob = rask.uploads().uploadFile(Path.of(path)).data();
// One call waits for a job, with the API's long-poll. A job that failed is thrown, not returned.
Job imported = rask.jobs().wait(importJob.id()).data();
if (!(imported.result() instanceof MediaImportJobResult)) {
stop("Job " + imported.id() + " has no media.");
}
return ((MediaImportJobResult) imported.result()).mediaId();
});
// The minutes shown once are the most this program spends: they are the cap of every paid request below.
int maxMinutes = Integer.parseInt(once("max_minutes", () -> {
Estimate estimate = rask.projects().estimateProject(EstimateProjectRequest.builder()
.mediaId(mediaId)
.targetLanguage("es-es")
.build()).data();
if (!estimate.eligible()) {
stop("Cannot start: " + estimate.reasons().stream().map(EstimateReason::code).collect(Collectors.joining(", ")));
}
System.out.println("The dub costs " + estimate.totalMinutes() + " minutes.");
return String.valueOf(estimate.totalMinutes());
}));
// The SDK makes an Idempotency-Key by itself, but a new one on every run. This key is saved before it is sent.
Job created = rask.projects().createProject(
CreateProjectRequest.builder().mediaId(mediaId).targetLanguage("es-es").expectedCostMinutes(maxMinutes).build(),
RequestOptions.builder().idempotencyKey(once("create_key", () -> UUID.randomUUID().toString())).build()).data();
Job processed = rask.jobs().wait(created.id()).data();
if (!(processed.result() instanceof ProjectProcessingJobResult)) {
stop("Job " + processed.id() + " has no project.");
}
ProjectProcessingJobResult processing = (ProjectProcessingJobResult) processed.result();
String projectId = processing.projectId();
// That job ends when translation ends. The dub is a job of its own.
ProjectProcessingAutoDubResult autoDub = processing.autoDub();
if (AutoDubResultState.DISPATCHED.equals(autoDub.state()) && autoDub.dubJobId() != null) {
rask.jobs().wait(autoDub.dubJobId());
} else {
// The dub was skipped (skippedReason says why) or not requested, and nothing was charged. Dub within the same cap.
Estimate estimate = rask.dubbing().estimateDub(projectId).data();
if (estimate.totalMinutes() > maxMinutes) {
stop("The dub now costs " + estimate.totalMinutes() + " minutes, more than the " + maxMinutes + " you accepted,"
+ " and nothing was charged. Project " + projectId + " keeps its translation: dub it once you accept"
+ " the new price.");
}
Job dub = rask.dubbing().createDub(projectId,
CreateDubRequest.builder().expectedCostMinutes(maxMinutes).build(),
RequestOptions.builder().idempotencyKey(once("dub_key", () -> UUID.randomUUID().toString())).build()).data();
rask.jobs().wait(dub.id());
}
// The link is presigned: the download takes no key, and it works for 1 hour.
for (Output output : rask.exports().listProjectOutputs(projectId).data().data()) {
if (OutputKind.DUBBED_VIDEO.equals(output.kind()) && output.url() != null) {
HttpResponse<InputStream> download = HttpClient.newHttpClient().send(
HttpRequest.newBuilder(URI.create(output.url())).build(), HttpResponse.BodyHandlers.ofInputStream());
try (InputStream body = download.body()) {
if (download.statusCode() != 200) {
stop("The download answered " + download.statusCode() + ". Run this again for a fresh link.");
}
Files.copy(body, Path.of("dubbed.mp4"), StandardCopyOption.REPLACE_EXISTING);
}
System.out.println("Saved dubbed.mp4");
return;
}
}
stop("Project " + projectId + " has no dubbed video to download.");
}
/** The value saved under this name, or a new one, saved before the program goes on. */
private static String once(String name, Supplier<String> make) {
if (!STATE.containsKey(name)) {
STATE.setProperty(name, make.get());
try (Writer writer = Files.newBufferedWriter(statePath)) {
STATE.store(writer, null);
} catch (IOException error) {
throw new UncheckedIOException(error);
}
}
return STATE.getProperty(name);
}
private static void stop(String message) {
System.err.println(message);
System.exit(1);
}
}
<?php
// Upload a video, dub it into Spanish and download the result: php dub.php talk.mp4
// What it has done is saved in talk.mp4.dub.json. If it stops, run it again: it sends the same keys, and the API replays
// what already ran, so no project or dub is created or charged twice. Delete the file to dub the video anew.
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use Rask\Sdk\Enum\AutoDubResultState;
use Rask\Sdk\Enum\OutputKind;
use Rask\Sdk\Exception\ConflictError;
use Rask\Sdk\Exception\ConnectionError;
use Rask\Sdk\Exception\InsufficientBalanceError;
use Rask\Sdk\Exception\JobCanceledError;
use Rask\Sdk\Exception\JobFailedError;
use Rask\Sdk\Exception\RaskError;
use Rask\Sdk\Exception\UsageError;
use Rask\Sdk\Exception\WaitTimeoutError;
use Rask\Sdk\Model\MediaImportJobResult;
use Rask\Sdk\Model\ProjectProcessingJobResult;
use Rask\Sdk\Rask;
/** What the program has done, saved in a file so that a second run goes on from where the first stopped. */
final class Progress
{
/** @var array<string, string> */
private array $values = [];
public function __construct(public readonly string $path)
{
if (is_file($path)) {
/** @var array<string, string> $values */
$values = json_decode((string) file_get_contents($path), true, flags: JSON_THROW_ON_ERROR);
$this->values = $values;
}
}
/**
* The value saved under this name, or a new one, saved before the program goes on.
*
* @param callable(): string $make
*/
public function once(string $name, callable $make): string
{
if (!isset($this->values[$name])) {
$this->values[$name] = $make();
file_put_contents($this->path, json_encode($this->values, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
}
return $this->values[$name];
}
}
function stop(string $message): never
{
fwrite(STDERR, $message . "\n");
exit(1);
}
$path = $argv[1] ?? 'talk.mp4';
$progress = new Progress($path . '.dub.json');
try {
$rask = new Rask(); // The key from RASK_API_KEY, and the base URL from RASK_BASE_URL when it is set.
$mediaId = $progress->once('media_id', function () use ($rask, $path): string {
// One call uploads the file: createUpload, the PUT to storage and completeUpload. It answers with the import job.
$importJob = $rask->uploads->uploadFile($path);
// One call waits for a job, with the API's long-poll. A job that failed is thrown, not returned.
$imported = $rask->jobs->wait((string) $importJob->id);
if (!$imported->result instanceof MediaImportJobResult || $imported->result->media_id === null) {
stop("Job {$imported->id} has no media.");
}
return $imported->result->media_id;
});
$order = ['media_id' => $mediaId, 'target_language' => 'es-es'];
// The minutes shown once are the most this program spends: they are the cap of every paid request below.
$maxMinutes = (int) $progress->once('max_minutes', function () use ($rask, $order): string {
$estimate = $rask->projects->estimateProject($order);
if ($estimate->eligible !== true) {
stop('Cannot start: ' . implode(', ', array_map(fn ($reason) => (string) $reason->code, $estimate->reasons ?? [])));
}
echo "The dub costs {$estimate->total_minutes} minutes.\n";
return (string) $estimate->total_minutes;
});
// The SDK makes an Idempotency-Key by itself, but a new one on every run. This key is saved before it is sent.
$created = $rask->projects->createProject(
[...$order, 'expected_cost_minutes' => $maxMinutes],
['idempotency_key' => $progress->once('create_key', fn (): string => bin2hex(random_bytes(16)))],
);
$processed = $rask->jobs->wait((string) $created->id);
$result = $processed->result;
if (!$result instanceof ProjectProcessingJobResult || $result->project_id === null) {
stop("Job {$processed->id} has no project.");
}
$projectId = $result->project_id;
// That job ends when translation ends. The dub is a job of its own.
$autoDub = $result->auto_dub;
if ($autoDub?->state === AutoDubResultState::DISPATCHED && $autoDub->dub_job_id !== null) {
$rask->jobs->wait($autoDub->dub_job_id);
} else {
// The dub was skipped (skipped_reason says why) or not requested, and nothing was charged. Dub within the same cap.
$estimate = $rask->dubbing->estimateDub($projectId);
if ($estimate->total_minutes > $maxMinutes) {
stop("The dub now costs {$estimate->total_minutes} minutes, more than the {$maxMinutes} you accepted, and nothing "
. "was charged. Project {$projectId} keeps its translation: dub it once you accept the new price.");
}
$dub = $rask->dubbing->createDub(
$projectId,
['expected_cost_minutes' => $maxMinutes],
['idempotency_key' => $progress->once('dub_key', fn (): string => bin2hex(random_bytes(16)))],
);
$rask->jobs->wait((string) $dub->id);
}
// The link is presigned: the download takes no key, and it works for 1 hour.
foreach ($rask->exports->listProjectOutputs($projectId)->data ?? [] as $output) {
if ($output->kind === OutputKind::DUBBED_VIDEO && $output->url !== null) {
// copy() answers false when storage refuses, as it does once the link has expired.
if (!copy($output->url, 'dubbed.mp4')) {
stop('The download failed. Run this again for a fresh link.');
}
echo "Saved dubbed.mp4\n";
exit(0);
}
}
stop("Project {$projectId} has no dubbed video to download.");
} catch (InsufficientBalanceError $error) {
// credit_type says which balance: minutes, or the project credits that createProject takes.
stop("{$error->detail} Add to the balance in the web app, then run this again.");
} catch (ConflictError $error) {
if ($error->code === 'cost_changed') {
stop("The price is now {$error->current_cost_minutes} minutes, more than the {$error->expected_cost_minutes} you "
. "accepted, and nothing was charged. Delete {$progress->path} to start again at the new price.");
}
stop($error->getMessage());
} catch (JobFailedError $error) {
stop("Job {$error->job->id} failed: {$error->code}: {$error->detail}");
} catch (JobCanceledError $error) {
stop("Job {$error->job->id} was canceled.");
} catch (WaitTimeoutError $error) {
stop("Job {$error->job_id} is still running. Run this again to keep waiting.");
} catch (ConnectionError $error) {
stop("No answer from the API: {$error->getMessage()}. Run this again: it sends the same keys.");
} catch (RaskError $error) {
// The base of them all, so it comes last.
stop($error->getMessage());
} catch (UsageError $error) {
// No key, or a wrong argument: nothing was sent.
stop($error->getMessage());
}
- Running it. The TypeScript file is an ES module, because it awaits at the top level: set
"type": "module"inpackage.json, or name the filedub.mts. Node runs a TypeScript file without a build step from version 22.18. - The upload asks for an upload, sends the bytes to storage without your key and completes it. It answers with the
media_importjob, whose result names the media. The SDK tells the content type by the file’s extension: for a name it cannot tell, pass the type. - The price. estimateProject gives it as
total_minutes. The program sends that asexpected_cost_minutes, the most it may spend, and keeps it as the cap of the dub it may start later. The project also takes a project credit, which the estimate counts intotal_projects. Whileprojects.unlimitedis true in getBalance, it takes none, andtotal_projectsis 0. - The wait uses the long-poll of getJob, for up to 4 hours unless you pass a longer time, as
below. A
file_restorejob or a longlocalizationrun can take more: how long a job takes gives the times. A job that failed or was canceled comes back as an error that holds the job. A wait that ran out of time comes back as an error that holds the job’s id, and leaves the job running: wait again with that id. - The dub is a job of its own. The job that createProject starts ends when translation
ends. Its
result.auto_dubnames thedub_job_idto wait for when itsstateisdispatched. When it isskipped(skipped_reasonsays why) ornot_requested, nothing was charged for a dub: the program prices one with estimateDub and starts it with createDub within the same cap, or stops if the price went up. - The keys. Every SDK sends an
Idempotency-Keyby itself and repeats it on its own retries, but a new run makes new keys. So the program saves each key in a file next to the video, with the media and the price, before it sends the request. Run it again after a crash or a timeout and it sends the same keys: the API replays what already ran and charges nothing twice. The API keeps a key 24 hours, and a key replays its job even when the job failed or was canceled, so delete the file to start new work. Idempotency says why. - The download. An output’s link is presigned: it takes no key, and it works for 1 hour. Downloads describes every output.
- Errors are one class for each HTTP status under one base class, and each holds the
codeto branch on. Errors lists the codes. These are the classes a dub program meets, under the same names in every SDK:
| Class | When | What to do |
|---|---|---|
InsufficientBalanceError | 402 insufficient_balance: the balance cannot pay. credit_type names the balance, minutes or the project credits that createProject takes, with what is needed and what is left | Add to the balance in the web app, then run the program again |
ConflictError | 409. With the code cost_changed, the price went above expected_cost_minutes, and current_cost_minutes holds the new one | After cost_changed, nothing was charged, and the key can be used again. Once you accept the new price, send it as expected_cost_minutes, with the same key or a new one |
JobFailedError | The job failed. Its code is the job’s error | Errors says what each code needs. A new attempt needs a new key |
JobCanceledError | The job was canceled | Start the work again with a new key |
WaitTimeoutError | The wait ran out of time, and the job is still running | Wait again with the job’s id |
RaskConnectionError | No answer came, after the SDK’s own retries. Go and PHP call it ConnectionError | Run the program again: it sends the same keys |
RaskError | Any other error. It is the base class, so check it last | Branch on code |
What every SDK does for you
- Keeps the key where it belongs. The key goes only to the base URL. A presigned upload goes without it, and a redirect that leaves the base URL drops it.
- Retries safely. A request that can be retried is sent again with the same
Idempotency-Key, so nothing is charged twice. Idempotency explains the rule the SDK follows. - Waits for jobs. One call waits for a job to finish, with the long-poll the API offers.
- Uploads a file in one call, and pages through a list for you.
- Survives new values. A value or a variant this version does not know reaches your code as it came, so a newer API does not break an older SDK.
- Gives typed errors, with the
codeto branch on. Errors lists them.
Base URL and wait timeout
The default ishttps://api.rask.ai/v3. To send requests through a gateway, set RASK_BASE_URL or pass the base URL to the
client. A path prefix is kept. A wait lasts up to 4 hours unless you pass a longer time to it.
| SDK | Base URL | A wait of up to 12 hours |
|---|---|---|
| TypeScript | new Rask({ baseUrl: url }) | rask.jobs.wait(jobId, { timeoutSeconds: 12 * 3600 }) |
| Python | Rask(base_url=url) | client.jobs.wait(job_id, timeout=12 * 3600) |
| Go | rask.New(rask.WithBaseURL(url)) | client.Jobs.Wait(ctx, jobID, rask.WithWaitTimeout(12*time.Hour)) |
| Java | Rask.builder().baseUrl(url).build() | rask.jobs().wait(jobId, Duration.ofHours(12)) |
| PHP | new Rask(['base_url' => $url]) | $rask->jobs->wait($jobId, 12 * 3600) |
Without an SDK
Every guide here uses plain HTTP, and the contract is served athttps://api.rask.ai/v3/openapi.json, so any OpenAPI
generator can build a client. Introduction shows how to read it from code.