> ## Documentation Index
> Fetch the complete documentation index at: https://docs.api.rask.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# One request for dub, lip-sync and on-screen

> Order dub, lip-sync and on-screen translation as one Rask API job, under one price cap, and resume it if a stage fails.

A localization run does dub, lip-sync and on-screen translation in one job, under one price cap. The order is fixed, and each stage works on the previous result: on-screen
renders over the lip-synced video when both are ordered. There are no other orders.

You can start a run in two ways:

* **On an existing project:** [createLocalization](https://docs.api.rask.ai/operations/createLocalization.md), below.
* **On a new project:** the `localization` block of [createProject](https://docs.api.rask.ai/operations/createProject.md), shown in the [Quickstart](https://docs.api.rask.ai/v3/quickstart.md#4-do-it-all-in-one-request). Its dub stage is
  the auto-dub, priced on the processed media duration.

## What you can order

Order `lipsync`, `onscreen` or both, on a video project. `dub` is optional and defaults to mode `auto`. A dub alone is [createDub](https://docs.api.rask.ai/operations/createDub.md).

The key needs `dubbing:write` and `credits:spend`, plus `lipsync:write` when lip-sync is ordered and `onscreen:write` when on-screen is ordered. The plan needs
`lipsync` and `lipsync_paid` for lip-sync, `lipsync_enhanced` too for the Enhanced model, and `onscreen` for on-screen. A run never uses the free lip-sync run.

## 1. Estimate

```typescript TypeScript theme={null}
const { data: estimate } = await rask.dubbing.estimateLocalization({
  path: { project_id: projectId },
  body: { dub: { mode: 'auto' }, lipsync: { model: 'legacy' }, onscreen: { ui_language: 'en' } },
});
```

```python Python theme={null}
estimate = client.dubbing.estimate_localization(
    project_id, dub={"mode": "auto"}, lipsync={"model": "legacy"}, onscreen={"ui_language": "en"}
)
```

```go Go theme={null}
estimate, err := client.Dubbing.EstimateLocalization(ctx, projectID, api.EstimateLocalizationRequest{
	Dub:      &api.LocalizationDubInput{Mode: rask.Ptr(api.DubModeAuto)},
	Lipsync:  &api.LocalizationLipsyncInput{Model: api.LipsyncModelLegacy},
	Onscreen: &api.LocalizationOnscreenInput{UiLanguage: rask.Ptr("en")},
})
if err != nil {
	return err
}
```

```java Java theme={null}
Estimate estimate = rask.dubbing().estimateLocalization(projectId, EstimateLocalizationRequest.builder()
        .dub(LocalizationDubInput.builder().mode(DubMode.AUTO).build())
        .lipsync(LocalizationLipsyncInput.builder().model(LipsyncModel.LEGACY).build())
        .onscreen(LocalizationOnscreenInput.builder().uiLanguage("en").build())
        .build()).data();
```

```php PHP theme={null}
$estimate = $rask->dubbing->estimateLocalization($projectId, [
    'dub' => ['mode' => 'auto'],
    'lipsync' => ['model' => 'legacy'],
    'onscreen' => ['ui_language' => 'en'],
]);
```

```bash cURL theme={null}
curl -X POST "https://api.rask.ai/v3/projects/$PROJECT_ID/localizations/estimate" \
  -H "Authorization: Bearer $RASK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"dub": {"mode": "auto"}, "lipsync": {"model": "legacy"}, "onscreen": {"ui_language": "en"}}'
```

`total_minutes` is the single total. `details.stages` prices each stage:

| Field | Meaning |
| - | - |
| `details.stages[].name` | `dub`, `lipsync` or `onscreen`, in run order |
| `details.stages[].will_run` | False when the stage is not run |
| `details.stages[].minutes` | Today's price. It is charged when the stage starts |
| `details.stages[].not_run_reason` | Why the stage costs 0 |
| `details.dub` | The details of the dub stage, as [estimateDub](https://docs.api.rask.ai/operations/estimateDub.md) gives them. Null when the dub does not run |

**A finished stage is not paid twice.** A stage with a current result counts as succeeded, at no charge, and `not_run_reason` says so:

| `not_run_reason` | The stage is reused when |
| - | - |
| `dub_up_to_date` | There is nothing to voice |
| `lipsync_up_to_date` | A paid lip-sync of the same model is current. A free run does not count |
| `onscreen_up_to_date` | A finished on-screen run over the same source is current, whatever its `ui_language` |

Stages are judged in order, so a dub that runs again makes the lip-sync and on-screen after it run too. If every stage is reused, the job succeeds at once at 0 minutes. When the
dub is reused, a lip-sync of another model than the current paid one answers 409 [`lipsync_already_done`](https://docs.api.rask.ai/errors/lipsync_already_done).

## 2. Start it

```typescript TypeScript theme={null}
const { data: job } = await rask.dubbing.createLocalization({
  path: { project_id: projectId },
  body: {
    dub: { mode: 'auto' },
    lipsync: { model: 'legacy' },
    onscreen: { ui_language: 'en' },
    expected_cost_minutes: 7,
  },
  idempotencyKey: runKey,
});
```

```python Python theme={null}
job = client.dubbing.create_localization(
    project_id,
    dub={"mode": "auto"},
    lipsync={"model": "legacy"},
    onscreen={"ui_language": "en"},
    expected_cost_minutes=7,
    idempotency_key=run_key,
)
```

```go Go theme={null}
job, err := client.Dubbing.CreateLocalization(ctx, projectID, api.CreateLocalizationRequest{
	Dub:                 &api.LocalizationDubInput{Mode: rask.Ptr(api.DubModeAuto)},
	Lipsync:             &api.LocalizationLipsyncInput{Model: api.LipsyncModelLegacy},
	Onscreen:            &api.LocalizationOnscreenInput{UiLanguage: rask.Ptr("en")},
	ExpectedCostMinutes: 7,
}, rask.WithIdempotencyKey(runKey))
if err != nil {
	return err
}
```

```java Java theme={null}
Job job = rask.dubbing().createLocalization(
        projectId,
        CreateLocalizationRequest.builder()
                .dub(LocalizationDubInput.builder().mode(DubMode.AUTO).build())
                .lipsync(LocalizationLipsyncInput.builder().model(LipsyncModel.LEGACY).build())
                .onscreen(LocalizationOnscreenInput.builder().uiLanguage("en").build())
                .expectedCostMinutes(7)
                .build(),
        RequestOptions.builder().idempotencyKey(runKey).build()).data();
```

```php PHP theme={null}
$job = $rask->dubbing->createLocalization(
    $projectId,
    [
        'dub' => ['mode' => 'auto'],
        'lipsync' => ['model' => 'legacy'],
        'onscreen' => ['ui_language' => 'en'],
        'expected_cost_minutes' => 7,
    ],
    ['idempotency_key' => $runKey],
);
```

```bash cURL theme={null}
RUN_KEY=$(uuidgen)
curl -X POST "https://api.rask.ai/v3/projects/$PROJECT_ID/localizations" \
  -H "Authorization: Bearer $RASK_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $RUN_KEY" \
  -d '{"dub": {"mode": "auto"}, "lipsync": {"model": "legacy"}, "onscreen": {"ui_language": "en"}, "expected_cost_minutes": 7}'
```

The request is checked as a whole: a total above the cap is 409 [`cost_changed`](https://docs.api.rask.ai/errors/cost_changed), and a balance below it is 402
[`insufficient_balance`](https://docs.api.rask.ai/errors/insufficient_balance). A key whose monthly spend limit cannot cover the cap gets 403
[`token_spend_limit_exceeded`](https://docs.api.rask.ai/errors/token_spend_limit_exceeded).

Three cases answer 422 [`localization_combination_not_supported`](https://docs.api.rask.ai/errors/localization_combination_not_supported), and `reason` tells them apart. Nothing is charged:

| `reason` | Why |
| - | - |
| `nothing_ordered` | Neither `lipsync` nor `onscreen` was ordered |
| `audio_project` | Lip-sync or on-screen was ordered on an audio project |
| `plan_trims_lipsync` | The plan would trim the lip-sync of this video, and on-screen was also ordered. Order one of them or change the plan |

## 3. Follow the run

```typescript TypeScript theme={null}
const { data: job } = await rask.jobs.getJob({ path: { job_id: jobId }, query: { wait_seconds: 20 } });
```

```python Python theme={null}
job = client.jobs.get_job(job_id, wait_seconds=20)
```

```go Go theme={null}
job, err := client.Jobs.GetJob(ctx, jobID, &api.GetJobParams{WaitSeconds: rask.Ptr(int32(20))})
if err != nil {
	return err
}
```

```java Java theme={null}
Job job = rask.jobs().getJob(jobId, GetJobParams.builder().waitSeconds(20).build()).data();
```

```php PHP theme={null}
$job = $rask->jobs->getJob($jobId, ['wait_seconds' => 20]);
```

```bash cURL theme={null}
curl "https://api.rask.ai/v3/jobs/$JOB_ID?wait_seconds=20" \
  -H "Authorization: Bearer $RASK_API_KEY"
```

The job's `stages` has one entry per stage, each with its child `job_id`, its `status` (`pending`, `running`, `succeeded`, `failed`, `skipped`), `reused`, `stage`, `cost` and `error`.
`stage` on the job itself is the running stage's. A run cannot be canceled, and neither can the jobs of its stages.

**How the money moves.** Nothing is reserved. Each stage is charged when it starts, under the one cap. `cost` on the run is what is held so far: the stages charged and not refunded.
The cap you sent stays in `params.expected_cost_minutes`.

## 4. Get the video

The succeeded job's `result.final_output` is an output of kind `localized_video`, the video that holds every ordered change, with its `url`. When `available` is false,
`url` is null and `unavailable_reason` says why: for example `not_in_plan` when lip-sync is the last stage and the plan has no `lipsync_download`. The same output is in
[listProjectOutputs](https://docs.api.rask.ai/operations/listProjectOutputs.md). `result.notices` carries the notices of the stages.

## 5. Resume a failed run

A run ends `failed` in one of two ways:

| `error.code` | What happened | Money |
| - | - | - |
| [`localization_stage_failed`](https://docs.api.rask.ai/errors/localization_stage_failed) | A stage's job failed, or something else kept the stage from starting, such as no face in the video | A failed job is refunded by its type's rule. A stage that did not start was never charged |
| [`localization_stage_skipped`](https://docs.api.rask.ai/errors/localization_stage_skipped) | A stage could not be paid, would have exceeded the run's cap or the key's spend limit, or (on-screen) would run on a lip-sync the plan now trims | The stage was never charged |

In both, `error.failed_stage` names the stage and `error.cause_code` the cause. Stages that succeeded stay charged, and unreached ones were never charged.

Remove the cause, for example by adding minutes, then price what is left. `resume_job_id` makes the estimate price the resume: stages that succeeded cost 0, with `not_run_reason:
already_succeeded`.

```typescript TypeScript theme={null}
const { data: estimate } = await rask.dubbing.estimateLocalization({
  path: { project_id: projectId },
  body: { resume_job_id: 'a4d81f6e-37c2-4b09-8e5d-1c7f3a9b6e20' },
});
```

```python Python theme={null}
estimate = client.dubbing.estimate_localization(project_id, resume_job_id="a4d81f6e-37c2-4b09-8e5d-1c7f3a9b6e20")
```

```go Go theme={null}
estimate, err := client.Dubbing.EstimateLocalization(ctx, projectID, api.EstimateLocalizationRequest{
	ResumeJobId: rask.Ptr("a4d81f6e-37c2-4b09-8e5d-1c7f3a9b6e20"),
})
if err != nil {
	return err
}
```

```java Java theme={null}
Estimate estimate = rask.dubbing().estimateLocalization(projectId, EstimateLocalizationRequest.builder()
        .resumeJobId("a4d81f6e-37c2-4b09-8e5d-1c7f3a9b6e20")
        .build()).data();
```

```php PHP theme={null}
$estimate = $rask->dubbing->estimateLocalization($projectId, [
    'resume_job_id' => 'a4d81f6e-37c2-4b09-8e5d-1c7f3a9b6e20',
]);
```

```bash cURL theme={null}
curl -X POST "https://api.rask.ai/v3/projects/$PROJECT_ID/localizations/estimate" \
  -H "Authorization: Bearer $RASK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"resume_job_id": "a4d81f6e-37c2-4b09-8e5d-1c7f3a9b6e20"}'
```

Restarted stages are priced today, so the resume can cost more than the run had left. Compare its `total_minutes` with the
price accepted for the run, less the failed job's `cost.minutes`. If it is higher, ask whoever accepted the price before you
resume: a run that stopped on `cost_changed` stopped so that a higher price is not charged without them.

Then resume. The key also needs `jobs:write`, and the scopes of the stages that restart.

```typescript TypeScript theme={null}
const { data: job } = await rask.jobs.resumeJob({
  path: { job_id: jobId },
  body: { expected_cost_minutes: 6 },
  idempotencyKey: resumeKey,
});
```

```python Python theme={null}
job = client.jobs.resume_job(job_id, expected_cost_minutes=6, idempotency_key=resume_key)
```

```go Go theme={null}
job, err := client.Jobs.ResumeJob(ctx, jobID, api.ResumeJobRequest{
	ExpectedCostMinutes: 6,
}, rask.WithIdempotencyKey(resumeKey))
if err != nil {
	return err
}
```

```java Java theme={null}
Job job = rask.jobs().resumeJob(
        jobId,
        ResumeJobRequest.builder().expectedCostMinutes(6).build(),
        RequestOptions.builder().idempotencyKey(resumeKey).build()).data();
```

```php PHP theme={null}
$job = $rask->jobs->resumeJob($jobId, ['expected_cost_minutes' => 6], ['idempotency_key' => $resumeKey]);
```

```bash cURL theme={null}
RESUME_KEY=$(uuidgen)
curl -X POST "https://api.rask.ai/v3/jobs/$JOB_ID/resume" \
  -H "Authorization: Bearer $RASK_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $RESUME_KEY" \
  -d '{"expected_cost_minutes": 6}'
```

The answer is a **new** `localization` job with `parent_job_id` set to the failed one, because a job's status never moves backwards. It lists the succeeded stages with their child jobs,
and never runs or charges them again. Failed, skipped and unreached stages start at today's price, in order, unless a current result is reused at no charge. A dub stage that restarts this way is a manual dub: its `text_adaptation` is `as_written`, and it is priced on speech seconds.

A failed run can be resumed once. Otherwise resuming answers 409 [`job_not_resumable`](https://docs.api.rask.ai/errors/job_not_resumable), and `reason` says why:

| `reason` | Meaning |
| - | - |
| `not_failed_localization` | Only a failed localization job can be resumed |
| `already_resumed` | It was resumed before. `job_id` names the job that continues it |
| `project_deleted` | The project was deleted |
| `stage_result_gone` | A later dub or lip-sync replaced the result of a stage the run counted as succeeded |

In those cases, start a new run.

## The whole program

```python run_localization.py theme={null}
"""Run all three stages on a project and resume once on failure: python run_localization.py PROJECT_ID

Its keys are saved in localization-PROJECT_ID.json: run it again within 24 hours, and no paid work starts twice. Delete the file to
start anew.
"""
import sys

from rask_client import Progress, call, download, wait_for_job

project_id = sys.argv[1]
progress = Progress(f"localization-{project_id}.json")

order = {"dub": {"mode": "auto"}, "lipsync": {"model": "legacy"}, "onscreen": {"ui_language": "en"}}
estimate = call("POST", f"/projects/{project_id}/localizations/estimate", order)
if not estimate["eligible"]:
    sys.exit(f"Cannot start: {estimate['reasons']}")
for stage in estimate["details"]["stages"]:
    print(stage["name"], stage["minutes"], stage["not_run_reason"])

job = call(
    "POST",
    f"/projects/{project_id}/localizations",
    {**order, "expected_cost_minutes": estimate["total_minutes"]},
    key=progress.key("run"),
)
run = wait_for_job(job["id"], check=False)

if run["status"] == "failed":
    print("Stopped at", run["error"]["failed_stage"], "because", run["error"]["cause_code"])
    remaining = call("POST", f"/projects/{project_id}/localizations/estimate", {"resume_job_id": run["id"]})
    # Stay within the price accepted for the whole run: what is left of it after the stages already charged.
    left = estimate["total_minutes"] - (run["cost"]["minutes"] if run["cost"] else 0)
    if remaining["total_minutes"] > left:
        sys.exit(f"Resuming costs {remaining['total_minutes']} minutes, more than the {left} left of the price accepted.")
    resumed = call(
        "POST",
        f"/jobs/{run['id']}/resume",
        {"expected_cost_minutes": remaining["total_minutes"]},
        key=progress.key("resume"),
    )
    run = wait_for_job(resumed["id"])
elif run["status"] != "succeeded":
    sys.exit(f"The run ended {run['status']}: {run['error']['code']}")

final = run["result"]["final_output"]
if not final["available"]:
    sys.exit(f"The video has no link: {final['unavailable_reason']}")
download(final["url"], "localized.mp4")
```


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.