mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-21 01:12:40 -04:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e3f6628c1 | ||
|
|
214eda6818 | ||
|
|
47648b19ae | ||
|
|
379e528e0a | ||
|
|
9965e2544d |
@@ -1,5 +1,10 @@
|
||||
# ArtCraft Omni API
|
||||
|
||||
> # 📚 Full API reference: **<https://storyteller-docs.netlify.app/>**
|
||||
>
|
||||
> **Every ArtCraft API endpoint is documented there.** This guide covers only the Omni API video
|
||||
> endpoints — for everything else, start at the link above.
|
||||
|
||||
The **Omni API** is the API-key–authenticated surface for ArtCraft generation. It mirrors the
|
||||
in-app "Omni Gen" endpoints but is designed for programmatic use: you authenticate with an API key
|
||||
in the `Authorization` header instead of a browser session cookie.
|
||||
@@ -25,43 +30,21 @@ All paths below are relative to the base URL (e.g. `POST /v1/omni_api/generate/v
|
||||
The examples in this guide use the **development** base URL. To run against production, swap
|
||||
`http://localhost:12345` for `https://api.storyteller.ai`.
|
||||
|
||||
Note: Our APIs are hosted on "`api.storyteller.ai`", not "`api.getartcraft.com`". This is not a
|
||||
typo. Our Cloudflare CDN URLs are `cdn-2.fakeyou.com`.
|
||||
|
||||
---
|
||||
|
||||
## Getting an API key
|
||||
|
||||
API keys are created from a logged-in session. You only need to do this once; store the key
|
||||
somewhere safe.
|
||||
Create and manage API keys from the ArtCraft web app:
|
||||
|
||||
1. **Log in** to obtain a session (browser, or the `/v1/login` endpoint).
|
||||
2. **Create a key** by calling `POST /v1/api_keys/create` with your session:
|
||||
1. Go to **<https://app.getartcraft.com/>**
|
||||
2. Click **Account → Settings → API Keys** (your account must have API keys enabled from our staff)
|
||||
3. Create a key and copy it. The secret is shown **once** — store it somewhere safe.
|
||||
|
||||
```bash
|
||||
curl -s -c /tmp/cookies.txt -X POST http://localhost:12345/v1/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username_or_email":"YOUR_USERNAME","password":"YOUR_PASSWORD"}'
|
||||
|
||||
curl -s -b /tmp/cookies.txt -X POST http://localhost:12345/v1/api_keys/create \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"my-integration","maybe_description":"server-to-server video gen"}'
|
||||
```
|
||||
|
||||
The response returns the secret **once** — it can never be retrieved again:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"api_key_token": "api_key_xxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
"api_key": "artcraft_api_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
}
|
||||
```
|
||||
|
||||
- `api_key` — the **secret**. Store it now; treat it like a password. This is the value you put
|
||||
in the `Authorization` header.
|
||||
- `api_key_token` — a non-secret handle (prefix `api_key_`) used to manage the key later
|
||||
(get / update / delete). Safe to log and store.
|
||||
|
||||
> **403 Forbidden when creating a key?** API-key creation is gated per account. Contact the
|
||||
> Storyteller team to enable API access for your account.
|
||||
> **Don't see the API Keys section?** API access is gated per account. Contact the Artcraft team
|
||||
> to enable it.
|
||||
|
||||
### What an API key looks like
|
||||
|
||||
@@ -69,7 +52,7 @@ somewhere safe.
|
||||
- Followed by 40 random lowercase Crockford-base32 characters (`0-9 a-z`, excluding `i l o u`)
|
||||
- Total length: **53 characters**, e.g. `artcraft_api_3k7q9w0xv2hs5n8m4d6r1t8y0p2s4f6h8j0k2m4n6`
|
||||
|
||||
Never commit a key to source control or paste it into logs.
|
||||
Treat the key like a password: never commit it to source control or paste it into logs.
|
||||
|
||||
---
|
||||
|
||||
@@ -412,6 +395,223 @@ console.log(resp.status, await resp.json());
|
||||
|
||||
---
|
||||
|
||||
## Checking job status
|
||||
|
||||
Generation is asynchronous. The generate call returns an `inference_job_token`; poll the job-status
|
||||
endpoint until the job reaches a terminal state, then read the result URL.
|
||||
|
||||
### Endpoints
|
||||
|
||||
- **Single job:** `GET /v1/omni_api/job_status/job/{token}`
|
||||
- **Batch (many jobs):** `GET /v1/omni_api/job_status/batch?tokens=jinf_aaa&tokens=jinf_bbb`
|
||||
|
||||
Both require the same `Authorization: Bearer …` header (no cookies).
|
||||
|
||||
### Response shape
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"state": {
|
||||
"job_token": "jinf_xxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
"request": {
|
||||
"inference_category": "video_generation",
|
||||
"maybe_prompt_token": "prompt_xxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
"maybe_model_type": "seedance_2p0",
|
||||
"maybe_model_token": null
|
||||
},
|
||||
"status": {
|
||||
"status": "pending",
|
||||
"maybe_first_started_at": null,
|
||||
"maybe_failure_category": null,
|
||||
"progress_percentage": 0
|
||||
},
|
||||
"maybe_result": null,
|
||||
"created_at": "2026-06-24T04:06:14Z",
|
||||
"updated_at": "2026-06-24T04:06:14Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `state.status.status` — the job state. Keep polling while it's `pending`, `started`, or
|
||||
`attempt_failed`. **Terminal** states are `complete_success`, `complete_failure`, `dead`,
|
||||
`cancelled_by_user`, and `cancelled_by_system`.
|
||||
- `state.status.progress_percentage` — an integer 0–100.
|
||||
- `state.maybe_result` — `null` until the job succeeds. On `complete_success` it contains the
|
||||
finished media; the playable/downloadable URL is **`state.maybe_result.media_links.cdn_url`**.
|
||||
For video, `media_links.maybe_video_previews` also carries still/animated preview URLs.
|
||||
|
||||
The batch endpoint returns `{ "success": true, "job_states": [ … ] }` where each entry is the same
|
||||
`state` payload as above.
|
||||
|
||||
### Where the result video / image lives
|
||||
|
||||
When `state.status.status` is `complete_success`, the finished media is under `state.maybe_result`:
|
||||
|
||||
- **`state.maybe_result.media_links.cdn_url`** — the primary, playable/downloadable URL of the
|
||||
finished asset. **This is the field you want.** For a video job it's the `.mp4`; for an image job
|
||||
it's the image file (`.jpg` / `.png` / etc.).
|
||||
- `state.maybe_result.entity_token` — the `m_…` media-file token for the result.
|
||||
- `state.maybe_result.media_links.maybe_video_previews` — **video jobs only**. Contains a `still`
|
||||
(poster `.jpg`) and an `animated` (`.gif`) preview URL, plus `{WIDTH}` thumbnail templates. This
|
||||
is `null` for image jobs.
|
||||
- `state.maybe_result.media_links.maybe_thumbnail_template` — **image jobs only**. A URL template;
|
||||
replace `{WIDTH}` with a pixel width to fetch a resized thumbnail. `null` for video jobs.
|
||||
|
||||
**Example — completed video job:**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"state": {
|
||||
"job_token": "jinf_9fcn2qv4dv3vvbrrxbnmfgen06v",
|
||||
"request": {
|
||||
"inference_category": "video_generation",
|
||||
"maybe_prompt_token": "prompt_q53tp4rh97qh8q159p21tazzt",
|
||||
"maybe_model_type": "seedance_2p0",
|
||||
"maybe_model_token": null
|
||||
},
|
||||
"status": {
|
||||
"status": "complete_success",
|
||||
"maybe_first_started_at": "2026-06-24T04:07:11Z",
|
||||
"maybe_failure_category": null,
|
||||
"progress_percentage": 100
|
||||
},
|
||||
"maybe_result": {
|
||||
"entity_type": "media_file",
|
||||
"entity_token": "m_ss682134tx19yhqs5y0nbnd0bd59a6",
|
||||
"media_links": {
|
||||
"cdn_url": "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/8/p/n/0/4/8pn04v7q3vfhf60wy173f6awcwkre1qj/artcraft_8pn04v7q3vfhf60wy173f6awcwkre1qj.mp4",
|
||||
"maybe_thumbnail_template": null,
|
||||
"maybe_video_previews": {
|
||||
"still": "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/8/p/n/0/4/8pn04v7q3vfhf60wy173f6awcwkre1qj/artcraft_8pn04v7q3vfhf60wy173f6awcwkre1qj.mp4-thumb.jpg",
|
||||
"animated": "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/8/p/n/0/4/8pn04v7q3vfhf60wy173f6awcwkre1qj/artcraft_8pn04v7q3vfhf60wy173f6awcwkre1qj.mp4-thumb.gif",
|
||||
"still_thumbnail_template": "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/8/p/n/0/4/8pn04v7q3vfhf60wy173f6awcwkre1qj/artcraft_8pn04v7q3vfhf60wy173f6awcwkre1qj.mp4-thumb-{WIDTH}.jpg",
|
||||
"animated_thumbnail_template": "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/8/p/n/0/4/8pn04v7q3vfhf60wy173f6awcwkre1qj/artcraft_8pn04v7q3vfhf60wy173f6awcwkre1qj.mp4-thumb-{WIDTH}.gif"
|
||||
}
|
||||
},
|
||||
"maybe_successfully_completed_at": "2026-06-24T04:17:30Z"
|
||||
},
|
||||
"created_at": "2026-06-24T04:06:14Z",
|
||||
"updated_at": "2026-06-24T04:17:30Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
→ The video is at
|
||||
`https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/8/p/n/0/4/8pn04v7q3vfhf60wy173f6awcwkre1qj/artcraft_8pn04v7q3vfhf60wy173f6awcwkre1qj.mp4`
|
||||
(example, not a live URL).
|
||||
|
||||
**Example — completed image job** (from `POST /v1/omni_api/generate/image`):
|
||||
|
||||
```json
|
||||
{
|
||||
"maybe_result": {
|
||||
"entity_type": "media_file",
|
||||
"entity_token": "m_3kq9w0xv2hs5n8m4d6r1t8y0p2s4f6",
|
||||
"media_links": {
|
||||
"cdn_url": "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/3/k/q/9/w/3kq9w0xv2hs5n8m4d6r1t8y0p2s4f6/artcraft_3kq9w0xv2hs5n8m4d6r1t8y0p2s4f6.jpg",
|
||||
"maybe_thumbnail_template": "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/3/k/q/9/w/3kq9w0xv2hs5n8m4d6r1t8y0p2s4f6/artcraft_3kq9w0xv2hs5n8m4d6r1t8y0p2s4f6.jpg-thumb-{WIDTH}.jpg",
|
||||
"maybe_video_previews": null
|
||||
},
|
||||
"maybe_successfully_completed_at": "2026-06-24T04:09:02Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
→ The image is at
|
||||
`https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/3/k/q/9/w/3kq9w0xv2hs5n8m4d6r1t8y0p2s4f6/artcraft_3kq9w0xv2hs5n8m4d6r1t8y0p2s4f6.jpg`
|
||||
(example, not a live URL).
|
||||
|
||||
### Poll until complete
|
||||
|
||||
#### bash
|
||||
|
||||
```bash
|
||||
JOB_TOKEN="jinf_xxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
|
||||
while :; do
|
||||
resp=$(curl -s "http://localhost:12345/v1/omni_api/job_status/job/$JOB_TOKEN" \
|
||||
-H "Authorization: Bearer YOUR_API_KEY")
|
||||
status=$(echo "$resp" | python3 -c "import sys,json;print(json.load(sys.stdin)['state']['status']['status'])")
|
||||
echo "status=$status"
|
||||
case "$status" in
|
||||
complete_success)
|
||||
echo "$resp" | python3 -c "import sys,json;print(json.load(sys.stdin)['state']['maybe_result']['media_links']['cdn_url'])"
|
||||
break ;;
|
||||
complete_failure|dead|cancelled_by_user|cancelled_by_system)
|
||||
echo "job ended without a result: $status"; break ;;
|
||||
esac
|
||||
sleep 5
|
||||
done
|
||||
```
|
||||
|
||||
#### python
|
||||
|
||||
```python
|
||||
import time
|
||||
import requests
|
||||
|
||||
BASE_URL = "http://localhost:12345" # production: https://api.storyteller.ai
|
||||
API_KEY = "YOUR_API_KEY"
|
||||
JOB_TOKEN = "jinf_xxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
|
||||
TERMINAL_FAILURES = {"complete_failure", "dead", "cancelled_by_user", "cancelled_by_system"}
|
||||
|
||||
while True:
|
||||
resp = requests.get(
|
||||
f"{BASE_URL}/v1/omni_api/job_status/job/{JOB_TOKEN}",
|
||||
headers={"Authorization": f"Bearer {API_KEY}"},
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
state = resp.json()["state"]
|
||||
status = state["status"]["status"]
|
||||
print("status:", status, state["status"]["progress_percentage"], "%")
|
||||
|
||||
if status == "complete_success":
|
||||
print("result URL:", state["maybe_result"]["media_links"]["cdn_url"])
|
||||
break
|
||||
if status in TERMINAL_FAILURES:
|
||||
print("job ended without a result:", status)
|
||||
break
|
||||
time.sleep(5)
|
||||
```
|
||||
|
||||
#### javascript
|
||||
|
||||
```javascript
|
||||
const BASE_URL = "http://localhost:12345"; // production: https://api.storyteller.ai
|
||||
const API_KEY = "YOUR_API_KEY";
|
||||
const JOB_TOKEN = "jinf_xxxxxxxxxxxxxxxxxxxxxxxxx";
|
||||
|
||||
const TERMINAL_FAILURES = new Set([
|
||||
"complete_failure", "dead", "cancelled_by_user", "cancelled_by_system",
|
||||
]);
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
while (true) {
|
||||
const resp = await fetch(`${BASE_URL}/v1/omni_api/job_status/job/${JOB_TOKEN}`, {
|
||||
headers: { Authorization: `Bearer ${API_KEY}` },
|
||||
});
|
||||
const { state } = await resp.json();
|
||||
const status = state.status.status;
|
||||
console.log("status:", status, state.status.progress_percentage, "%");
|
||||
|
||||
if (status === "complete_success") {
|
||||
console.log("result URL:", state.maybe_result.media_links.cdn_url);
|
||||
break;
|
||||
}
|
||||
if (TERMINAL_FAILURES.has(status)) {
|
||||
console.log("job ended without a result:", status);
|
||||
break;
|
||||
}
|
||||
await sleep(5000);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes & troubleshooting
|
||||
|
||||
- **Always send a fresh `idempotency_token`** (UUID). Reusing one is rejected as a duplicate.
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod omni_api_batch_get_job_status;
|
||||
pub mod omni_api_get_job_status;
|
||||
pub mod omni_api_job_status_payload;
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
|
||||
use crate::omni_api::job_status::omni_api_job_status_payload::OmniApiJobStatusPayload;
|
||||
|
||||
/// Query params for `GET /v1/omni_api/job_status/batch`.
|
||||
#[derive(Deserialize, ToSchema, IntoParams)]
|
||||
pub struct OmniApiBatchGetJobStatusQueryParams {
|
||||
/// A grab bag of job tokens. Each token family generally has its own prefix,
|
||||
/// so a flat set is sufficient.
|
||||
///
|
||||
/// NB: handlers parse this with `actix_web_lab`'s `Query<T>` because the
|
||||
/// default actix-web `Query<T>` doesn't decode repeated/sequence params yet.
|
||||
/// See https://github.com/actix/actix-web/issues/1301
|
||||
pub tokens: HashSet<String>,
|
||||
}
|
||||
|
||||
/// Success body for `GET /v1/omni_api/job_status/batch`.
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct OmniApiBatchGetJobStatusSuccessResponse {
|
||||
pub success: bool,
|
||||
pub job_states: Vec<OmniApiJobStatusPayload>,
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
|
||||
|
||||
use crate::omni_api::job_status::omni_api_job_status_payload::OmniApiJobStatusPayload;
|
||||
|
||||
/// Path params for `GET /v1/omni_api/job_status/job/{token}`.
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct OmniApiGetJobStatusPathInfo {
|
||||
pub token: InferenceJobToken,
|
||||
}
|
||||
|
||||
/// Success body for `GET /v1/omni_api/job_status/job/{token}`.
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct OmniApiGetJobStatusSuccessResponse {
|
||||
pub success: bool,
|
||||
pub state: OmniApiJobStatusPayload,
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use enums::by_table::generic_inference_jobs::frontend_failure_category::FrontendFailureCategory;
|
||||
use enums::by_table::generic_inference_jobs::inference_category::InferenceCategory;
|
||||
use enums::common::job_status_plus::JobStatusPlus;
|
||||
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
|
||||
use tokens::tokens::prompts::PromptToken;
|
||||
|
||||
use crate::common::responses::media_links::MediaLinks;
|
||||
|
||||
/// Full status payload for a single inference job.
|
||||
///
|
||||
/// Shared by both the single (`/v1/omni_api/job_status/job/{token}`) and batch
|
||||
/// (`/v1/omni_api/job_status/batch`) Omni API status endpoints. Forked from the
|
||||
/// cookie-authenticated `/v1/jobs/*` response types so the Omni API surface can
|
||||
/// evolve independently.
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct OmniApiJobStatusPayload {
|
||||
pub job_token: InferenceJobToken,
|
||||
|
||||
pub request: OmniApiJobRequestDetails,
|
||||
pub status: OmniApiJobStatusDetails,
|
||||
pub maybe_result: Option<OmniApiJobResultDetails>,
|
||||
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Details about what the user requested for generation.
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct OmniApiJobRequestDetails {
|
||||
pub inference_category: InferenceCategory,
|
||||
|
||||
pub maybe_prompt_token: Option<PromptToken>,
|
||||
|
||||
pub maybe_model_type: Option<String>,
|
||||
pub maybe_model_token: Option<String>,
|
||||
}
|
||||
|
||||
/// Details about the ongoing job status.
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct OmniApiJobStatusDetails {
|
||||
/// Primary status from the database (a state machine).
|
||||
pub status: JobStatusPlus,
|
||||
|
||||
pub maybe_first_started_at: Option<DateTime<Utc>>,
|
||||
|
||||
/// An enum the frontend can use to display localized/I18N error
|
||||
/// messages. These pertain to both transient and permanent failures.
|
||||
pub maybe_failure_category: Option<FrontendFailureCategory>,
|
||||
|
||||
/// This is an integer number between 0 and 100 (both inclusive) that
|
||||
/// reports the completeness.
|
||||
pub progress_percentage: u8,
|
||||
}
|
||||
|
||||
/// Details about the completed result (if any).
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct OmniApiJobResultDetails {
|
||||
pub entity_type: String,
|
||||
pub entity_token: String,
|
||||
|
||||
/// Rich CDN links to the media, including thumbnails, previews, and more.
|
||||
pub media_links: MediaLinks,
|
||||
|
||||
pub maybe_successfully_completed_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod generate_requests;
|
||||
pub mod job_status;
|
||||
pub mod omni_upload_audio;
|
||||
pub mod omni_upload_image;
|
||||
pub mod omni_upload_video;
|
||||
|
||||
@@ -349,6 +349,9 @@ use artcraft_api_defs::omni_gen::generate_response::omni_gen_image_generate_resp
|
||||
use artcraft_api_defs::omni_gen::generate_response::omni_gen_video_generate_response::*;
|
||||
use artcraft_api_defs::omni_gen::models::omni_gen_image_models::*;
|
||||
use artcraft_api_defs::omni_gen::models::omni_gen_video_models::*;
|
||||
use artcraft_api_defs::omni_api::job_status::omni_api_batch_get_job_status::*;
|
||||
use artcraft_api_defs::omni_api::job_status::omni_api_get_job_status::*;
|
||||
use artcraft_api_defs::omni_api::job_status::omni_api_job_status_payload::*;
|
||||
use artcraft_api_defs::omni_api::omni_upload_audio::*;
|
||||
use artcraft_api_defs::omni_api::omni_upload_image::*;
|
||||
use artcraft_api_defs::omni_api::omni_upload_video::*;
|
||||
@@ -573,6 +576,8 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
crate::http_server::endpoints::omni_api::upload::omni_upload_audio_media_file_handler::omni_upload_audio_media_file_handler,
|
||||
crate::http_server::endpoints::omni_api::upload::omni_upload_image_media_file_handler::omni_upload_image_media_file_handler,
|
||||
crate::http_server::endpoints::omni_api::upload::omni_upload_video_media_file_handler::omni_upload_video_media_file_handler,
|
||||
crate::http_server::endpoints::omni_api::job_status::get_job_status_handler::omni_api_get_job_status_handler,
|
||||
crate::http_server::endpoints::omni_api::job_status::batch_get_job_status_handler::omni_api_batch_get_job_status_handler,
|
||||
// Moderation
|
||||
crate::http_server::endpoints::moderation::alerts::moderation_send_alert_handler::moderation_send_alert_handler,
|
||||
crate::http_server::endpoints::moderation::info::moderator_token_info_handler::moderator_get_token_info_handler,
|
||||
@@ -826,7 +831,13 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
GetCharacterResponse,
|
||||
ListCharactersEntry,
|
||||
ListCharactersResponse,
|
||||
OmniApiBatchGetJobStatusSuccessResponse,
|
||||
OmniApiGetJobStatusSuccessResponse,
|
||||
OmniApiImageGenerateRequest,
|
||||
OmniApiJobRequestDetails,
|
||||
OmniApiJobResultDetails,
|
||||
OmniApiJobStatusDetails,
|
||||
OmniApiJobStatusPayload,
|
||||
OmniApiVideoGenerateRequest,
|
||||
OmniGenImageCostAndGenerateRequest,
|
||||
OmniGenImageCostResponse,
|
||||
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::web::Json;
|
||||
use actix_web::{web, HttpRequest};
|
||||
use actix_web_lab::extract::Query;
|
||||
use log::{error, warn};
|
||||
use redis::Commands;
|
||||
|
||||
use artcraft_api_defs::omni_api::job_status::omni_api_batch_get_job_status::{OmniApiBatchGetJobStatusQueryParams, OmniApiBatchGetJobStatusSuccessResponse};
|
||||
use mysql_queries::queries::generic_inference::web::batch_get_inference_job_status::batch_get_inference_job_status;
|
||||
use mysql_queries::queries::generic_inference::web::job_status::GenericInferenceJobStatus;
|
||||
use redis_common::redis_keys::RedisKeys;
|
||||
use server_environment::ServerEnvironment;
|
||||
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::common_responses::media::media_domain::MediaDomain;
|
||||
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
|
||||
use crate::http_server::endpoints::omni_api::job_status::record_to_payload::record_to_payload;
|
||||
use crate::http_server::user_lookup::api_keys::require_api_key_user::require_api_key_user;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
/// For certain jobs or job classes (eg. non-premium), we kill the jobs if the user hasn't
|
||||
/// maintained a keepalive. This prevents wasted work when users who are unlikely to return
|
||||
/// navigate away. Premium users have accounts and can always return to the site, so they
|
||||
/// typically do not require keepalive.
|
||||
const JOB_KEEPALIVE_TTL_SECONDS: u64 = 60 * 3;
|
||||
|
||||
/// Get job statuses for a batch of multiple job tokens (Omni API, API-key authenticated).
|
||||
///
|
||||
/// Mirrors `/v1/jobs/batch` but reads the caller's identity from the
|
||||
/// `Authorization` header API key instead of a session cookie.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
tag = "Omni API",
|
||||
path = "/v1/omni_api/job_status/batch",
|
||||
responses(
|
||||
(status = 200, body = OmniApiBatchGetJobStatusSuccessResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 500),
|
||||
),
|
||||
params(
|
||||
OmniApiBatchGetJobStatusQueryParams
|
||||
)
|
||||
)]
|
||||
pub async fn omni_api_batch_get_job_status_handler(
|
||||
http_request: HttpRequest,
|
||||
query: Query<OmniApiBatchGetJobStatusQueryParams>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<OmniApiBatchGetJobStatusSuccessResponse>, CommonWebError> {
|
||||
// ==================== API KEY USER ==================== //
|
||||
|
||||
// API-key authentication (Authorization header) instead of a session cookie. Never cached, and a
|
||||
// banned owner is rejected inside `require_api_key_user`. Drop the connection before the job
|
||||
// lookups below so we don't hold a pool slot idle.
|
||||
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
|
||||
require_api_key_user(&http_request, &mut *mysql_connection).await?;
|
||||
drop(mysql_connection);
|
||||
|
||||
let tokens = query.tokens.iter()
|
||||
.map(|token| token.trim())
|
||||
// NB: A bunch of Python clients use our API and can fail in this manner.
|
||||
.filter(|token| !token.is_empty() && *token != "None")
|
||||
.map(|token| InferenceJobToken::new_from_str(token))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if tokens.is_empty() {
|
||||
// NB: MediaDomain doesn't matter since it's an empty list.
|
||||
return Ok(records_to_response(
|
||||
Vec::new(),
|
||||
server_state.server_environment,
|
||||
MediaDomain::Storyteller,
|
||||
));
|
||||
}
|
||||
|
||||
let records = batch_get_inference_job_status(
|
||||
&tokens,
|
||||
&server_state.mysql_pool,
|
||||
).await
|
||||
.map_err(|err| {
|
||||
warn!("Batch job query error: {:?}", err);
|
||||
CommonWebError::from_anyhow_error(err)
|
||||
})?;
|
||||
|
||||
let mut redis = server_state.redis_pool
|
||||
.get()
|
||||
.map_err(|e| {
|
||||
warn!("Redis pool error: {:?}", e);
|
||||
CommonWebError::from_error(e)
|
||||
})?;
|
||||
|
||||
// TODO(bt,2024-04-22): Look up the extra redis statuses per item.
|
||||
|
||||
let keepalive_keys = records.iter()
|
||||
.filter(|record| record.is_keepalive_required)
|
||||
.map(|record| RedisKeys::generic_inference_keepalive(record.job_token.as_str()))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
for key in keepalive_keys.iter() {
|
||||
// TODO(bt,2024-04-22): There is no msetex. We'll need to run a Redis pipeline here.
|
||||
let _: Option<String> = match redis.set_ex(key, "1", JOB_KEEPALIVE_TTL_SECONDS) {
|
||||
Ok(Some(status)) => Some(status),
|
||||
Ok(None) => None,
|
||||
Err(e) => {
|
||||
error!("redis error setting job keepalive: {:?}", e);
|
||||
None // Fail open (which in this case is bad! it will kill jobs if cluster has many jobs / is slow!)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let media_domain = get_media_domain(&http_request);
|
||||
|
||||
Ok(records_to_response(records, server_state.server_environment, media_domain))
|
||||
}
|
||||
|
||||
fn records_to_response(
|
||||
records: Vec<GenericInferenceJobStatus>,
|
||||
server_environment: ServerEnvironment,
|
||||
media_domain: MediaDomain,
|
||||
) -> Json<OmniApiBatchGetJobStatusSuccessResponse> {
|
||||
Json(OmniApiBatchGetJobStatusSuccessResponse {
|
||||
success: true,
|
||||
job_states: records.into_iter()
|
||||
.map(|record| record_to_payload(record, server_environment, media_domain))
|
||||
.collect::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::web::{Json, Path};
|
||||
use actix_web::{web, HttpRequest};
|
||||
use log::{error, warn};
|
||||
use redis::Commands;
|
||||
|
||||
use artcraft_api_defs::omni_api::job_status::omni_api_get_job_status::{OmniApiGetJobStatusPathInfo, OmniApiGetJobStatusSuccessResponse};
|
||||
use mysql_queries::queries::generic_inference::web::get_inference_job_status::get_inference_job_status;
|
||||
use redis_common::redis_keys::RedisKeys;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
|
||||
use crate::http_server::endpoints::omni_api::job_status::record_to_payload::record_to_payload;
|
||||
use crate::http_server::user_lookup::api_keys::require_api_key_user::require_api_key_user;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
/// For certain jobs or job classes (eg. non-premium), we kill the jobs if the user hasn't
|
||||
/// maintained a keepalive. This prevents wasted work when users who are unlikely to return
|
||||
/// navigate away. Premium users have accounts and can always return to the site, so they
|
||||
/// typically do not require keepalive.
|
||||
const JOB_KEEPALIVE_TTL_SECONDS: u64 = 60 * 3;
|
||||
|
||||
/// Get the status for a single job (Omni API, API-key authenticated).
|
||||
///
|
||||
/// Mirrors `/v1/jobs/job/{token}` but reads the caller's identity from the
|
||||
/// `Authorization` header API key instead of a session cookie.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
tag = "Omni API",
|
||||
path = "/v1/omni_api/job_status/job/{token}",
|
||||
params(
|
||||
("path" = OmniApiGetJobStatusPathInfo, description = "Path params for Request")
|
||||
),
|
||||
responses(
|
||||
(status = 200, body = OmniApiGetJobStatusSuccessResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404),
|
||||
(status = 500),
|
||||
),
|
||||
)]
|
||||
pub async fn omni_api_get_job_status_handler(
|
||||
http_request: HttpRequest,
|
||||
path: Path<OmniApiGetJobStatusPathInfo>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<OmniApiGetJobStatusSuccessResponse>, CommonWebError> {
|
||||
// ==================== API KEY USER ==================== //
|
||||
|
||||
// API-key authentication (Authorization header) instead of a session cookie. Never cached, and a
|
||||
// banned owner is rejected inside `require_api_key_user`. Drop the connection before the job
|
||||
// lookups below so we don't hold a pool slot idle.
|
||||
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
|
||||
require_api_key_user(&http_request, &mut *mysql_connection).await?;
|
||||
drop(mysql_connection);
|
||||
|
||||
if path.token.as_str().trim() == "None" {
|
||||
// NB: A bunch of Python clients use our API and can fail in this manner.
|
||||
return Err(CommonWebError::NotFound);
|
||||
}
|
||||
|
||||
let maybe_status = get_inference_job_status(
|
||||
&path.token,
|
||||
&server_state.mysql_pool,
|
||||
).await;
|
||||
|
||||
let record = match maybe_status {
|
||||
Ok(Some(record)) => record,
|
||||
Ok(None) => return Err(CommonWebError::NotFound),
|
||||
Err(err) => {
|
||||
warn!("Job query error for token {}: {:?}", path.token.as_str(), err);
|
||||
return Err(CommonWebError::from_anyhow_error(err));
|
||||
}
|
||||
};
|
||||
|
||||
let mut redis = server_state.redis_pool
|
||||
.get()
|
||||
.map_err(|e| {
|
||||
warn!("Redis pool error: {:?}", e);
|
||||
CommonWebError::from_error(e)
|
||||
})?;
|
||||
|
||||
if record.is_keepalive_required {
|
||||
// TODO(bt,2023-05-21): Make async.
|
||||
let extra_status_key = RedisKeys::generic_inference_extra_status_info(path.token.as_str());
|
||||
let _: Option<String> = match redis.set_ex(&extra_status_key, "1", JOB_KEEPALIVE_TTL_SECONDS) {
|
||||
Ok(Some(status)) => Some(status),
|
||||
Ok(None) => None,
|
||||
Err(e) => {
|
||||
error!("redis error setting job keepalive: {:?}", e);
|
||||
None // Fail open (which in this case is bad! it will kill jobs if cluster has many jobs / is slow!)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let media_domain = get_media_domain(&http_request);
|
||||
|
||||
let state = record_to_payload(
|
||||
record,
|
||||
server_state.server_environment,
|
||||
media_domain,
|
||||
);
|
||||
|
||||
Ok(Json(OmniApiGetJobStatusSuccessResponse {
|
||||
success: true,
|
||||
state,
|
||||
}))
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod batch_get_job_status_handler;
|
||||
pub mod get_job_status_handler;
|
||||
pub mod record_to_payload;
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
use artcraft_api_defs::omni_api::job_status::omni_api_job_status_payload::{
|
||||
OmniApiJobRequestDetails, OmniApiJobResultDetails, OmniApiJobStatusDetails, OmniApiJobStatusPayload,
|
||||
};
|
||||
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
|
||||
use bucket_paths::legacy::typified_paths::public::voice_conversion_results::bucket_file_path::VoiceConversionResultOriginalFilePath;
|
||||
use enums::by_table::generic_inference_jobs::inference_category::InferenceCategory;
|
||||
use mysql_queries::queries::generic_inference::web::job_status::GenericInferenceJobStatus;
|
||||
use server_environment::ServerEnvironment;
|
||||
|
||||
use crate::http_server::common_responses::media::media_domain::MediaDomain;
|
||||
use crate::http_server::common_responses::media::media_links_builder::MediaLinksBuilder;
|
||||
use crate::http_server::endpoints::inference_job::utils::estimates::estimate_job_progress::estimate_job_progress;
|
||||
use crate::http_server::endpoints::inference_job::utils::extractors::extract_polymorphic_inference_args::extract_polymorphic_inference_args;
|
||||
use crate::http_server::web_utils::filter_model_name::maybe_filter_model_name;
|
||||
|
||||
/// Convert a DB job-status record into the Omni API response payload.
|
||||
///
|
||||
/// This is the same mapping the cookie-authenticated `/v1/jobs/*` handlers use;
|
||||
/// it's shared by both the single and batch Omni API status handlers.
|
||||
#[allow(deprecated)] // We match deprecated `InferenceCategory` variants in the result-path mapping.
|
||||
pub fn record_to_payload(
|
||||
record: GenericInferenceJobStatus,
|
||||
server_environment: ServerEnvironment,
|
||||
media_domain: MediaDomain,
|
||||
) -> OmniApiJobStatusPayload {
|
||||
let inference_category = record.request_details.inference_category;
|
||||
|
||||
// NB: Fail open. We don't want to fail the request if we can't extract the args.
|
||||
let maybe_polymorphic_args = extract_polymorphic_inference_args(&record)
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
let progress_percentage = estimate_job_progress(&record, maybe_polymorphic_args.as_ref());
|
||||
|
||||
OmniApiJobStatusPayload {
|
||||
job_token: record.job_token,
|
||||
request: OmniApiJobRequestDetails {
|
||||
inference_category: record.request_details.inference_category,
|
||||
maybe_prompt_token: record.request_details.maybe_prompt_token,
|
||||
maybe_model_type: maybe_filter_model_name(record.request_details.maybe_model_type.as_deref()),
|
||||
maybe_model_token: record.request_details.maybe_model_token,
|
||||
},
|
||||
status: OmniApiJobStatusDetails {
|
||||
status: record.status,
|
||||
maybe_first_started_at: record.maybe_first_started_at,
|
||||
maybe_failure_category: record.maybe_frontend_failure_category,
|
||||
progress_percentage,
|
||||
},
|
||||
maybe_result: record.maybe_result_details.map(|result_details| {
|
||||
// NB: Be careful here, because this varies based on the type of inference result.
|
||||
let public_bucket_media_path = match inference_category {
|
||||
// NB: TTS has to be special cased due to legacy behavior
|
||||
InferenceCategory::TextToSpeech
|
||||
| InferenceCategory::F5TTS => {
|
||||
match result_details.entity_type.as_str() {
|
||||
"media_file" => {
|
||||
// NB: We're migrating TTS to media_files.
|
||||
// Zero shot TTS uses media files.
|
||||
// Legacy TT2 uses old pathing.
|
||||
MediaFileBucketPath::from_object_hash(
|
||||
&result_details.public_bucket_location_or_hash,
|
||||
result_details.maybe_media_file_public_bucket_prefix.as_deref(),
|
||||
result_details.maybe_media_file_public_bucket_extension.as_deref())
|
||||
.get_full_object_path_str()
|
||||
.to_string()
|
||||
}
|
||||
_ => {
|
||||
// NB: TTS results receive the legacy treatment where their table only reports the full bucket path
|
||||
result_details.public_bucket_location_or_hash
|
||||
}
|
||||
}
|
||||
}
|
||||
// NB: Voice conversion has to be special cased due to legacy behavior
|
||||
InferenceCategory::VoiceConversion | InferenceCategory::SeedVc => {
|
||||
match result_details.entity_type.as_str() {
|
||||
"media_file" => {
|
||||
// NB: We're migrating voice conversion to media_files.
|
||||
MediaFileBucketPath::from_object_hash(
|
||||
&result_details.public_bucket_location_or_hash,
|
||||
result_details.maybe_media_file_public_bucket_prefix.as_deref(),
|
||||
result_details.maybe_media_file_public_bucket_extension.as_deref())
|
||||
.get_full_object_path_str()
|
||||
.to_string()
|
||||
}
|
||||
_ => {
|
||||
// NB: This is the old voice conversion result pathing.
|
||||
VoiceConversionResultOriginalFilePath::from_object_hash(&result_details.public_bucket_location_or_hash)
|
||||
.get_full_object_path_str()
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
// Unsupported media files.
|
||||
InferenceCategory::FormatConversion |
|
||||
InferenceCategory::ConvertBvhToWorkflow => {
|
||||
"".to_string()
|
||||
}
|
||||
// Deprecated
|
||||
InferenceCategory::DeprecatedField => {
|
||||
"".to_string() // TODO(bt,2024-07-16): Read job type instead
|
||||
}
|
||||
// The blessed path that modern media files use.
|
||||
_ => {
|
||||
MediaFileBucketPath::from_object_hash(
|
||||
&result_details.public_bucket_location_or_hash,
|
||||
result_details.maybe_media_file_public_bucket_prefix.as_deref(),
|
||||
result_details.maybe_media_file_public_bucket_extension.as_deref())
|
||||
.get_full_object_path_str()
|
||||
.to_string()
|
||||
}
|
||||
};
|
||||
|
||||
OmniApiJobResultDetails {
|
||||
entity_type: result_details.entity_type,
|
||||
entity_token: result_details.entity_token,
|
||||
media_links: MediaLinksBuilder::from_rooted_path_and_env(
|
||||
media_domain,
|
||||
server_environment,
|
||||
&public_bucket_media_path,
|
||||
),
|
||||
maybe_successfully_completed_at: result_details.maybe_successfully_completed_at,
|
||||
}
|
||||
}),
|
||||
created_at: record.created_at,
|
||||
updated_at: record.updated_at,
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod generate;
|
||||
pub mod job_status;
|
||||
pub mod shared_utils;
|
||||
pub mod upload;
|
||||
|
||||
+12
@@ -5,6 +5,8 @@ use actix_web::{web, App, Error, HttpResponse};
|
||||
|
||||
use crate::http_server::endpoints::omni_api::generate::image::omni_api_image_generate_handler::omni_api_image_generate_handler;
|
||||
use crate::http_server::endpoints::omni_api::generate::video::omni_api_video_generate_handler::omni_api_video_generate_handler;
|
||||
use crate::http_server::endpoints::omni_api::job_status::batch_get_job_status_handler::omni_api_batch_get_job_status_handler;
|
||||
use crate::http_server::endpoints::omni_api::job_status::get_job_status_handler::omni_api_get_job_status_handler;
|
||||
use crate::http_server::endpoints::omni_api::upload::omni_upload_audio_media_file_handler::omni_upload_audio_media_file_handler;
|
||||
use crate::http_server::endpoints::omni_api::upload::omni_upload_image_media_file_handler::omni_upload_image_media_file_handler;
|
||||
use crate::http_server::endpoints::omni_api::upload::omni_upload_video_media_file_handler::omni_upload_video_media_file_handler;
|
||||
@@ -49,5 +51,15 @@ where
|
||||
.route(web::head().to(|| HttpResponse::Ok()))
|
||||
)
|
||||
)
|
||||
.service(web::scope("/job_status")
|
||||
.service(web::resource("/job/{token}")
|
||||
.route(web::get().to(omni_api_get_job_status_handler))
|
||||
.route(web::head().to(|| HttpResponse::Ok()))
|
||||
)
|
||||
.service(web::resource("/batch")
|
||||
.route(web::get().to(omni_api_batch_get_job_status_handler))
|
||||
.route(web::head().to(|| HttpResponse::Ok()))
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user