mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-22 09:39:31 -04:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a0fa62cd7 | ||
|
|
126f874c90 | ||
|
|
65e72f8c24 | ||
|
|
fafb485a60 | ||
|
|
1e20f2bfff | ||
|
|
e30b4366ce | ||
|
|
d27d5cec75 | ||
|
|
749124f834 | ||
|
|
65b18b9b51 |
+47
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n api_keys.token as `api_key_token: ApiKeyToken`,\n users.token as `user_token: UserToken`,\n users.is_banned\nFROM api_keys\nINNER JOIN users\n ON users.token = api_keys.owner_user_token\nWHERE api_keys.api_key = ?\n AND api_keys.maybe_deleted_at IS NULL\nLIMIT 1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "api_key_token: ApiKeyToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "user_token: UserToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "is_banned",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "f52c495a7ff5cafb357f79c4cdb503bbbef045fd193a5a37a66180fcac77f363"
|
||||
}
|
||||
Generated
+9
@@ -712,6 +712,13 @@ dependencies = [
|
||||
"workspace-build-acceleration",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "artcraft_api_keys"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"workspace-build-acceleration",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "artcraft_client"
|
||||
version = "0.0.1"
|
||||
@@ -7297,6 +7304,7 @@ name = "mysql_queries"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"artcraft_api_keys",
|
||||
"bucket_paths",
|
||||
"chrono",
|
||||
"composite_identifiers",
|
||||
@@ -11556,6 +11564,7 @@ dependencies = [
|
||||
"actix_helpers",
|
||||
"anyhow",
|
||||
"artcraft_api_defs",
|
||||
"artcraft_api_keys",
|
||||
"artcraft_router",
|
||||
"async-stripe 0.26.0",
|
||||
"async-trait",
|
||||
|
||||
@@ -7,6 +7,7 @@ members = [
|
||||
# API clients for external services
|
||||
# These might be custom implementations or wrappers over other client libraries.
|
||||
"crates/api_clients/artcraft/artcraft_api_defs",
|
||||
"crates/api_clients/artcraft/artcraft_api_keys",
|
||||
"crates/api_clients/artcraft/artcraft_client",
|
||||
"crates/api_clients/artcraft/artcraft_router",
|
||||
"crates/api_clients/cloud_storage",
|
||||
@@ -232,6 +233,7 @@ workspace-build-acceleration = { path = "crates/workspace-build-acceleration" }
|
||||
actix_artcraft = { path = "crates/lib/actix_artcraft" }
|
||||
actix_helpers = { path = "crates/lib/actix_helpers" }
|
||||
artcraft_api_defs = { path = "crates/api_clients/artcraft/artcraft_api_defs" }
|
||||
artcraft_api_keys = { path = "crates/api_clients/artcraft/artcraft_api_keys" }
|
||||
artcraft_client = { path = "crates/api_clients/artcraft/artcraft_client" }
|
||||
artcraft_router = { path = "crates/api_clients/artcraft/artcraft_router" }
|
||||
bootstrap = { path = "crates/lib/bootstrap" }
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
# PageDraw — Visual Design & Architecture Review
|
||||
|
||||
**Surface:** `@storyteller/ui-pagedraw` image editor + its `artcraft-webapp` integration (`/edit-image`).
|
||||
**Lens:** Awwwards / Linear-tier "premium app chrome" rubric, adapted for an in-product creative tool.
|
||||
**Date:** 2026-06-15 · **Branch:** `worktree-pagedraw-webapp`
|
||||
|
||||
---
|
||||
|
||||
## 0. How to read this
|
||||
|
||||
PageDraw is a **professional canvas tool**, not a marketing page. So the elite-design rubric is
|
||||
applied in its *app-chrome* register — the north star is **Figma / Linear / Photoshop-web**, not an
|
||||
Awwwards landing page. That means:
|
||||
|
||||
- **Applies:** token discipline, concentric radii, hairline + inset-highlight surfaces ("double-bezel"),
|
||||
island/pill controls, custom-eased micro-motion, z-index hygiene, premium type ramp.
|
||||
- **Does _not_ apply:** `py-40` macro-whitespace, hero typography, editorial-split layouts, scroll-reveal
|
||||
entrances. A tool surface is dense and persistent by design; forcing landing-page whitespace would hurt it.
|
||||
|
||||
Citations are `file:line` against the current tree.
|
||||
|
||||
---
|
||||
|
||||
## 1. Verdict
|
||||
|
||||
| Axis | Score | One-liner |
|
||||
|---|---|---|
|
||||
| Color system | ⚠️ 5/10 | Good token foundation, but hardcoded greys (`#1b1b1b`, `#303030`, `#404040`) leak through a *themeable lib*. |
|
||||
| Typography | ❌ 3/10 | No type ramp, no font-family ownership; rides whatever the host sets. Banned-by-rubric default stack risk. |
|
||||
| Radii | ⚠️ 4/10 | Five radius scales in play (`sm`→`2xl`→`full`) with no concentric nesting. |
|
||||
| Surfaces (border/shadow) | ⚠️ 5/10 | Flat `shadow-lg` everywhere; no inset highlights; no double-bezel. Reads "functional", not "machined". |
|
||||
| Motion | ❌ 3/10 | Only `transition-colors`/`ease-in-out`, durations 75–200ms; **zero custom easing**, no spring, no entrance choreography. |
|
||||
| Layout / z-index | ⚠️ 6/10 | Clean fixed-panel composition, but a `z-[9999999]` escape hatch and a 6-tier ad-hoc z-stack. |
|
||||
| Icons | ✅ 7/10 | FontAwesome Pro (solid + regular) — acceptable, but solid weight is heavier than the rubric's "ultra-light line" ideal. |
|
||||
| Architecture | ⚠️ 5/10 | Clean adapter boundary; but `PaintSurface.tsx` (2005 LOC), `SceneState.ts` (1539), `PageDraw.tsx` (1292) are oversized, and the lib hardcodes host-theme colors. |
|
||||
|
||||
**Bottom line:** the editor is *structurally competent and functional* but visually reads as
|
||||
"capable internal tool," not "$150k product surface." The single highest-leverage fix is a **shared
|
||||
design-token layer** (color + radius + motion + type), because today the lib both (a) reaches for
|
||||
host tokens (`bg-ui-panel`, `text-base-fg`) *and* (b) hardcodes its own greys — so it can never be
|
||||
fully reskinned by the webapp vs. the desktop app.
|
||||
|
||||
---
|
||||
|
||||
## 2. What's already good (keep)
|
||||
|
||||
- **Token-aware base.** Most surfaces use semantic tokens (`bg-ui-panel`, `bg-ui-controls`,
|
||||
`text-base-fg`, `border-ui-panel-border`) — the right instinct (SideToolbar, PromptEditor, HistoryStack).
|
||||
- **Clean platform boundary.** The lib has zero REST/Tauri imports; everything I/O flows through
|
||||
`PageDrawAdapter` (`adapter.ts`). The webapp adapter (`web-adapter.tsx`) and desktop adapter stay
|
||||
swappable. This is genuinely good architecture and should be protected.
|
||||
- **Performance-minded canvas.** Composite pre-baking on idle (`PageDraw.tsx` ~661–710) and worker
|
||||
encoding (`generatePipeline.worker.ts`) keep the generate click off the main thread. Don't regress this.
|
||||
- **CORS already handled** for browser canvas export (`crossOrigin="anonymous"` in `Node.ts:134`,
|
||||
`SceneState.ts:1473`, `imageHelpers.ts:16`) — the web port works because of this.
|
||||
|
||||
---
|
||||
|
||||
## 3. Findings & recommendations
|
||||
|
||||
### 3.1 Color — stop hardcoding greys in a themeable lib ⚠️ P0
|
||||
**Current:** semantic tokens *and* raw hex coexist:
|
||||
- `#1b1b1b` pegboard (`pagedraw.css:6`), `#1A1A1A`/`#333333` context menu (`ContextMenu.tsx:44`),
|
||||
`#303030`/`#404040` dividers + popovers (`SideToolbar.tsx:381,550`), `bg-zinc-700/600` swatch
|
||||
(`SideToolbar.tsx:173`), plus ad-hoc `bg-blue-600/500` accents (`PageDraw.tsx:157`, base selector).
|
||||
- Accent color is inconsistent: the lib's primary action uses raw **blue-600**, while the webapp's
|
||||
design system uses `--primary` / `brand-primary`. So the editor's blue doesn't match the product.
|
||||
|
||||
**Why it fails the bar:** a premium component library exposes *one* surface ramp and *one* accent, both
|
||||
driven by tokens, so it reskins per host. Hardcoded greys mean the desktop and web themes can drift, and
|
||||
the raw `blue-600` clashes with `brand-primary` used elsewhere in the webapp.
|
||||
|
||||
**Do:**
|
||||
- Replace every raw grey with the existing ramp: `#1b1b1b`→`bg-ui-background`, `#1A1A1A`→`bg-ui-panel`,
|
||||
`#303030`/`#404040`→`border-ui-panel-border` / `bg-ui-controls`. For the pegboard, add a token
|
||||
`--st-canvas` so hosts can theme the canvas backdrop.
|
||||
- Replace `blue-600/500` accents with `bg-primary` / `text-primary` (the token the rest of the webapp uses),
|
||||
so the Generate/Edit-3D buttons match `brand-primary`.
|
||||
- Move the react-colorful overrides off pure `rgb(255,255,255)` borders (`pagedraw.css:15,19`) onto
|
||||
`white/15` hairlines for consistency with the rest of the chrome.
|
||||
|
||||
### 3.2 Typography — own a type ramp ❌ P0
|
||||
**Current:** no `font-family` anywhere in the lib (grep: 0 hits); only `text-xs/sm/base/lg/xl` +
|
||||
`font-medium/semibold/bold`. The editor inherits whatever the host's body font is.
|
||||
|
||||
**Why it fails the bar:** the rubric explicitly bans the default web stack (Inter/Roboto/Arial/Helvetica).
|
||||
Inheriting an unspecified stack means the tool can render in a banned font with no recourse, and there's
|
||||
no deliberate hierarchy (numerics in sliders, labels, modal titles all share one undifferentiated ramp).
|
||||
|
||||
**Do:**
|
||||
- Adopt the product's display/grotesk token (e.g. a `font-sans` mapped to Geist / Plus Jakarta Sans at
|
||||
the app level) and reference it in the lib's root container, not per-component.
|
||||
- Define a small semantic ramp: `text-[11px] tracking-[0.16em] uppercase` for toolbar/section labels,
|
||||
`text-sm` for controls, `text-base font-semibold` for modal titles. Apply `tabular-nums` to slider
|
||||
read-outs (`SliderWithIndicator.tsx`) and dimension fields (`BlankCanvasModal`) so numbers don't jitter.
|
||||
|
||||
### 3.3 Radii — collapse to a concentric scale ⚠️ P1
|
||||
**Current:** `rounded-sm / md / lg / xl / 2xl / full` all appear, with no parent→child relationship
|
||||
(e.g. `rounded-2xl` card at `PageDraw.tsx:1123`, `rounded-xl` panels in SideToolbar, `rounded-lg`
|
||||
buttons in InpaintToolBar).
|
||||
|
||||
**Do:** pick a 3-step system — **`rounded-2xl` shells, `rounded-xl` inner cards, `rounded-lg` controls,
|
||||
`rounded-full` pills** — and apply concentrically (the "double-bezel" curve math: inner radius =
|
||||
outer − padding). Drop `rounded-sm`/`rounded-md` from the editor entirely.
|
||||
|
||||
### 3.4 Surfaces — add the "machined" double-bezel ⚠️ P1
|
||||
**Current:** panels are a single `bg-ui-panel` + `border` + `shadow-lg`. No nested enclosure, no inset
|
||||
top-highlight. Reads flat.
|
||||
|
||||
**Do (for the four floating panels — SideToolbar, HistoryStack, PromptEditor, InpaintToolBar):**
|
||||
- Outer shell: `bg-ui-panel/80 ring-1 ring-white/10 backdrop-blur-xl p-1.5 rounded-2xl`.
|
||||
- Inner core: own bg + `shadow-[inset_0_1px_0_rgba(255,255,255,0.08)] rounded-xl`.
|
||||
- This is the single change that most moves the surface from "tool" to "premium hardware." The panels are
|
||||
already `fixed` (`PageDraw.tsx:1144,1175,1195`), so `backdrop-blur` here is GPU-safe per the perf rules.
|
||||
|
||||
### 3.5 Motion — replace linear/ease with spring-eased micro-motion ❌ P0 (cheap, high impact)
|
||||
**Current:** `transition-colors`/`transition-all` with `ease-in-out` and `duration-75/150/200`
|
||||
(`SideToolbar.tsx:381,550`, `SliderWithIndicator.tsx:65`, `ContextMenu.tsx:53`). No custom easing,
|
||||
no entrance/exit on popovers, color pickers, or the context menu (they pop instantly).
|
||||
|
||||
**Why it fails the bar:** instant state changes and `ease-in-out` are explicitly banned. A pro tool still
|
||||
deserves *interpolated* motion — it's what separates Figma from a jQuery plugin.
|
||||
|
||||
**Do:**
|
||||
- Standardize on one curve: `transition-[transform,opacity,background-color] duration-300
|
||||
ease-[cubic-bezier(0.32,0.72,0,1)]`.
|
||||
- Tool buttons: `active:scale-[0.97]` press physics; selected tool gets a spring-eased indicator slide
|
||||
rather than a hard background swap.
|
||||
- Popovers / color picker / context menu: animate in with `opacity-0 translate-y-1 scale-[0.98]` →
|
||||
resolved, ~180ms. Transform+opacity only (perf rule).
|
||||
- History tiles: stagger their `opacity` reveal when a generation lands (they already use
|
||||
`transition-opacity`, just add per-index delay).
|
||||
|
||||
### 3.6 Layout & z-index — tame the stack ⚠️ P1
|
||||
**Current:** clean fixed-panel composition, but z-index is ad-hoc across 6 tiers and includes a
|
||||
`z-[9999999]` on the context menu (`ContextMenu.tsx:44`) and a one-off `z-[60]` (`PageDraw.tsx:237`).
|
||||
|
||||
**Do:** define a named scale in one place — `canvas:0, chrome:10, floating-controls:20, overlay-button:40,
|
||||
menu:50` — and replace the magic numbers. The portal menu only needs to clear the chrome, not `9999999`.
|
||||
|
||||
### 3.7 Responsive — declare intent ⚠️ P2
|
||||
**Current:** essentially desktop-only; fixed sidebars assume a wide viewport; only LightboxDetails has
|
||||
`sm:` variants. The webapp wrapper scopes the lib correctly (`pagedraw.tsx` `translateZ(0)` containing
|
||||
block) but there's no mobile story.
|
||||
|
||||
**Do:** mirror the Edit-3D page's explicit **mobile gate** (`pagescene.tsx` `MobileGate`) on `/edit-image`
|
||||
rather than letting fixed toolbars overlap on touch. A precision canvas tool is legitimately desktop-first;
|
||||
just say so deliberately instead of degrading silently.
|
||||
|
||||
---
|
||||
|
||||
## 4. Architecture notes
|
||||
|
||||
- **Oversized modules.** `PaintSurface.tsx` (2005 LOC), `SceneState.ts` (1539), `PageDraw.tsx` (1292).
|
||||
These are review/maintenance hotspots. Suggest extracting from `SceneState.ts`: history/undo slice,
|
||||
base-image-load slice, and generation/pending slice into composable Zustand slices. `PageDraw.tsx`'s
|
||||
generate-flow (cases 1–3, ~780–868) could move to a `useGenerate()` hook.
|
||||
- **Theme ownership.** Per §3.1, the lib should *only* consume tokens. Today it mixes tokens + hex, which
|
||||
defeats the adapter pattern's promise (one component, many themed hosts).
|
||||
- **Adapter surface is healthy.** `PageDrawAdapter` is small and intentional; the web vs. Tauri split is
|
||||
clean. The webapp's `enqueueInpaint` stub and 402→credits-modal handling are explicit and documented.
|
||||
- **Icon weight.** FontAwesome **solid** is the heaviest option; if a line/light set is available
|
||||
(Phosphor/Remix or FA light), the toolbar would read more refined. Low priority.
|
||||
|
||||
---
|
||||
|
||||
## 5. Prioritized roadmap
|
||||
|
||||
| P | Item | Effort | Payoff |
|
||||
|---|---|---|---|
|
||||
| **P0** | Token sweep: kill hardcoded greys; unify accent on `--primary` (§3.1) | M | Highest — enables true theming + product-consistent accent |
|
||||
| **P0** | Own a font-family + semantic type ramp at the lib root (§3.2) | S | High — removes banned-font risk, adds hierarchy |
|
||||
| **P0** | One custom easing curve + press/popover micro-motion (§3.5) | S | High — cheapest "feels expensive" win |
|
||||
| **P1** | Double-bezel + inset highlights on the 4 floating panels (§3.4) | M | High — "machined" surface upgrade |
|
||||
| **P1** | Concentric 3-step radius system (§3.3) | S | Medium — visual coherence |
|
||||
| **P1** | Named z-index scale; remove `z-[9999999]` (§3.6) | S | Medium — maintainability |
|
||||
| **P2** | Explicit mobile gate on `/edit-image` (§3.7) | S | Medium — correctness on touch |
|
||||
| **P2** | Split `SceneState.ts` / `PaintSurface.tsx` into slices (§4) | L | Medium — maintainability |
|
||||
|
||||
---
|
||||
|
||||
## 6. Appendix — proposed token additions
|
||||
|
||||
```css
|
||||
/* canvas + chrome surface ramp (lib should consume only these) */
|
||||
--st-canvas: <theme>; /* replaces #1b1b1b pegboard */
|
||||
--st-accent: var(--primary); /* replaces blue-600/500 */
|
||||
--st-hairline: rgb(255 255 255 / 0.10);
|
||||
--st-inset-hi: inset 0 1px 0 rgb(255 255 255 / 0.08);
|
||||
|
||||
/* motion */
|
||||
--st-ease: cubic-bezier(0.32, 0.72, 0, 1);
|
||||
--st-dur-fast: 180ms;
|
||||
--st-dur: 300ms;
|
||||
|
||||
/* radius scale (concentric) */
|
||||
--st-r-shell: 1.25rem; /* rounded-2xl */
|
||||
--st-r-card: 0.875rem; /* rounded-xl */
|
||||
--st-r-control: 0.5rem; /* rounded-lg */
|
||||
```
|
||||
|
||||
> Scope note: every recommendation here is **styling/architecture only** — none change the generate,
|
||||
> polling, upload, bg-removal, or adapter contracts validated during the functional debug pass.
|
||||
@@ -8,6 +8,7 @@ pub mod generate;
|
||||
pub mod jobs;
|
||||
pub mod media_file;
|
||||
pub mod moderation;
|
||||
pub mod omni_api;
|
||||
pub mod omni_gen;
|
||||
pub mod prompts;
|
||||
pub mod stripe_artcraft;
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod omni_upload_audio;
|
||||
pub mod omni_upload_image;
|
||||
pub mod omni_upload_video;
|
||||
@@ -0,0 +1,15 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
/// Success response for the API-key authenticated audio upload endpoint
|
||||
/// (`POST /v1/omni_api/upload/audio`).
|
||||
///
|
||||
/// NB: The request is a `multipart/form-data` body. Because the multipart form type is bound to the
|
||||
/// web framework, it is declared alongside the handler in `storyteller_web` rather than here.
|
||||
#[derive(Clone, Serialize, Deserialize, ToSchema, Debug)]
|
||||
pub struct OmniUploadAudioMediaFileSuccessResponse {
|
||||
pub success: bool,
|
||||
pub media_file_token: MediaFileToken,
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
/// Success response for the API-key authenticated image upload endpoint
|
||||
/// (`POST /v1/omni_api/upload/image`).
|
||||
///
|
||||
/// NB: The request is a `multipart/form-data` body. Because the multipart form type is bound to the
|
||||
/// web framework, it is declared alongside the handler in `storyteller_web` rather than here.
|
||||
#[derive(Clone, Serialize, Deserialize, ToSchema, Debug)]
|
||||
pub struct OmniUploadImageMediaFileSuccessResponse {
|
||||
pub success: bool,
|
||||
pub media_file_token: MediaFileToken,
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
/// Success response for the API-key authenticated video upload endpoint
|
||||
/// (`POST /v1/omni_api/upload/video`).
|
||||
///
|
||||
/// NB: The request is a `multipart/form-data` body. Because the multipart form type is bound to the
|
||||
/// web framework, it is declared alongside the handler in `storyteller_web` rather than here.
|
||||
#[derive(Clone, Serialize, Deserialize, ToSchema, Debug)]
|
||||
pub struct OmniUploadVideoMediaFileSuccessResponse {
|
||||
pub success: bool,
|
||||
pub media_file_token: MediaFileToken,
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "artcraft_api_keys"
|
||||
edition = "2024"
|
||||
version = "0.0.1"
|
||||
authors = [
|
||||
"Brandon Thomas <bt@brand.io>",
|
||||
"Brandon Thomas <echelon@gmail.com>",
|
||||
]
|
||||
publish = false
|
||||
|
||||
[package.metadata.cargo-udeps.ignore]
|
||||
normal = ["workspace-build-acceleration"]
|
||||
|
||||
[lib]
|
||||
name = "artcraft_api_keys"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
# Hackari workspace (faster compile times)
|
||||
workspace-build-acceleration.workspace = true
|
||||
@@ -0,0 +1,104 @@
|
||||
use std::fmt;
|
||||
|
||||
use crate::parse_from_header_value::parse_from_header_value;
|
||||
|
||||
pub mod parse_from_header_value;
|
||||
|
||||
/// Number of leading characters shown when an [`ArtcraftApiKey`] is formatted. The remainder of
|
||||
/// the secret is redacted so the full key never lands in logs or error messages.
|
||||
const REDACTED_PREFIX_CHAR_COUNT: usize = 20;
|
||||
|
||||
/// An Artcraft API key (e.g. `artcraft_api_<entropy>`).
|
||||
///
|
||||
/// `Debug` and `Display` both redact the value, printing only the first
|
||||
/// [`REDACTED_PREFIX_CHAR_COUNT`] characters followed by an ellipsis so the full secret is never
|
||||
/// accidentally logged.
|
||||
#[derive(Clone, PartialEq, Eq, Hash)]
|
||||
pub struct ArtcraftApiKey(pub String);
|
||||
|
||||
impl ArtcraftApiKey {
|
||||
pub fn new_from_str(value: &str) -> ArtcraftApiKey {
|
||||
ArtcraftApiKey(value.to_string())
|
||||
}
|
||||
|
||||
/// Parse an [`ArtcraftApiKey`] from the value of an HTTP `Authorization` header, accepting the
|
||||
/// `Bearer <key>`, `Key <key>`, and bare `<key>` forms. See [`parse_from_header_value`] for the
|
||||
/// full parsing rules.
|
||||
pub fn parse_from_authorization_header_value(header_value: &str) -> Option<ArtcraftApiKey> {
|
||||
parse_from_header_value(header_value)
|
||||
}
|
||||
|
||||
/// The full, unredacted key value as a borrowed `&str`.
|
||||
///
|
||||
/// NB: unlike `Display`/`Debug` (which redact), this exposes the complete secret — the
|
||||
/// `_be_careful` suffix is a reminder to use it only where the real value is genuinely needed
|
||||
/// (storage, the create-key response, tests), never in logs or error messages.
|
||||
pub fn as_str_be_careful(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// The full, unredacted key value as an owned `String`. See [`Self::as_str_be_careful`] for the
|
||||
/// caveat behind the `_be_careful` suffix.
|
||||
pub fn to_string_be_careful(&self) -> String {
|
||||
self.0.clone()
|
||||
}
|
||||
|
||||
/// The redacted form used by both `Debug` and `Display`: the first
|
||||
/// [`REDACTED_PREFIX_CHAR_COUNT`] characters followed by an ellipsis.
|
||||
fn redacted(&self) -> String {
|
||||
let prefix: String = self.0.chars().take(REDACTED_PREFIX_CHAR_COUNT).collect();
|
||||
format!("{prefix}…")
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ArtcraftApiKey {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.redacted())
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for ArtcraftApiKey {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "ArtcraftApiKey({:?})", self.redacted())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const SAMPLE_KEY: &str = "artcraft_api_55ax0zhd580m598r6n4n7szdwjb2b28sypapvawh";
|
||||
|
||||
#[test]
|
||||
fn new_from_str_and_as_str_round_trip() {
|
||||
let key = ArtcraftApiKey::new_from_str(SAMPLE_KEY);
|
||||
assert_eq!(key.as_str_be_careful(), SAMPLE_KEY);
|
||||
assert_eq!(key.0, SAMPLE_KEY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_redacts_to_first_twenty_characters() {
|
||||
let key = ArtcraftApiKey::new_from_str(SAMPLE_KEY);
|
||||
assert_eq!(format!("{key}"), "artcraft_api_55ax0zh…");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_redacts_to_first_twenty_characters() {
|
||||
let key = ArtcraftApiKey::new_from_str(SAMPLE_KEY);
|
||||
assert_eq!(format!("{key:?}"), "ArtcraftApiKey(\"artcraft_api_55ax0zh…\")");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formatting_does_not_leak_the_full_secret() {
|
||||
let key = ArtcraftApiKey::new_from_str(SAMPLE_KEY);
|
||||
let suffix = &SAMPLE_KEY[REDACTED_PREFIX_CHAR_COUNT..];
|
||||
assert!(!format!("{key}").contains(suffix));
|
||||
assert!(!format!("{key:?}").contains(suffix));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shorter_than_prefix_is_printed_in_full() {
|
||||
let key = ArtcraftApiKey::new_from_str("abc");
|
||||
assert_eq!(format!("{key}"), "abc…");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
use crate::ArtcraftApiKey;
|
||||
|
||||
/// `Bearer <api_key>` — the widely-accepted style for most APIs.
|
||||
const BEARER_SCHEME: &str = "bearer";
|
||||
|
||||
/// `Key <api_key>` — legacy GitHub-style / Fal-style.
|
||||
const KEY_SCHEME: &str = "key";
|
||||
|
||||
/// Parse an [`ArtcraftApiKey`] from the value of an HTTP `Authorization` header.
|
||||
///
|
||||
/// The value is accepted in three forms:
|
||||
///
|
||||
/// - `Bearer <api_key>` (widely-accepted style for most APIs)
|
||||
/// - `Key <api_key>` (legacy GitHub-style / Fal-style)
|
||||
/// - `<api_key>` (AWS-style — the bare key, no scheme)
|
||||
///
|
||||
/// The scheme keyword is matched case-insensitively, and surrounding/inner whitespace is trimmed.
|
||||
/// Returns `None` if the value is empty, carries an unsupported scheme (e.g. `Basic ...`), or names
|
||||
/// a scheme with no credential.
|
||||
pub fn parse_from_header_value(header_value: &str) -> Option<ArtcraftApiKey> {
|
||||
let trimmed = header_value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let key = match trimmed.split_once(char::is_whitespace) {
|
||||
// A scheme word followed by a value: "Bearer <key>" / "Key <key>".
|
||||
Some((scheme, rest)) => {
|
||||
if is_supported_scheme(scheme) {
|
||||
let key = rest.trim();
|
||||
if key.is_empty() {
|
||||
// A supported scheme with no credential.
|
||||
return None;
|
||||
}
|
||||
key
|
||||
} else {
|
||||
// An unsupported scheme (e.g. "Basic ..."). A bare API key never
|
||||
// contains whitespace, so a multi-token value we don't recognize is
|
||||
// not a usable key.
|
||||
return None;
|
||||
}
|
||||
}
|
||||
// No whitespace: AWS-style bare API key ("<api_key>") — unless the value is
|
||||
// just a bare scheme keyword (a malformed header carrying no credential).
|
||||
None => {
|
||||
if is_supported_scheme(trimmed) {
|
||||
return None;
|
||||
}
|
||||
trimmed
|
||||
}
|
||||
};
|
||||
|
||||
Some(ArtcraftApiKey::new_from_str(key))
|
||||
}
|
||||
|
||||
fn is_supported_scheme(word: &str) -> bool {
|
||||
word.eq_ignore_ascii_case(BEARER_SCHEME) || word.eq_ignore_ascii_case(KEY_SCHEME)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const SAMPLE_KEY: &str = "artcraft_api_55ax0zhd580m598r6n4n7szdwjb2b28sypapvawh";
|
||||
|
||||
fn parsed(header_value: &str) -> Option<String> {
|
||||
parse_from_header_value(header_value).map(|key| key.to_string_be_careful())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bearer_scheme_is_case_insensitive() {
|
||||
assert_eq!(parsed("Bearer abc"), Some("abc".to_string()));
|
||||
assert_eq!(parsed("bearer abc"), Some("abc".to_string()));
|
||||
assert_eq!(parsed("BEARER abc"), Some("abc".to_string()));
|
||||
assert_eq!(parsed("BeArEr abc"), Some("abc".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_scheme_is_case_insensitive() {
|
||||
assert_eq!(parsed("Key abc"), Some("abc".to_string()));
|
||||
assert_eq!(parsed("key abc"), Some("abc".to_string()));
|
||||
assert_eq!(parsed("KEY abc"), Some("abc".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_key_aws_style() {
|
||||
assert_eq!(parsed("abc"), Some("abc".to_string()));
|
||||
assert_eq!(parsed(SAMPLE_KEY), Some(SAMPLE_KEY.to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surrounding_and_inner_whitespace_is_trimmed() {
|
||||
assert_eq!(parsed(" Bearer abc "), Some("abc".to_string()));
|
||||
assert_eq!(parsed("\tKey\tabc\t"), Some("abc".to_string()));
|
||||
assert_eq!(parsed(" abc "), Some("abc".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_or_blank_is_none() {
|
||||
assert_eq!(parsed(""), None);
|
||||
assert_eq!(parsed(" "), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scheme_with_no_credential_is_none() {
|
||||
assert_eq!(parsed("Bearer"), None);
|
||||
assert_eq!(parsed("Bearer "), None);
|
||||
assert_eq!(parsed("Key"), None);
|
||||
assert_eq!(parsed("key "), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_scheme_is_none() {
|
||||
assert_eq!(parsed("Basic dXNlcjpwYXNz"), None);
|
||||
assert_eq!(parsed("Digest abc"), None);
|
||||
// Two non-scheme tokens — a bare key never contains whitespace.
|
||||
assert_eq!(parsed("abc def"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_an_artcraft_api_key() {
|
||||
let key = parse_from_header_value(&format!("Bearer {SAMPLE_KEY}")).expect("should parse");
|
||||
assert_eq!(key.as_str_be_careful(), SAMPLE_KEY);
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ path = "src/lib.rs"
|
||||
workspace-build-acceleration.workspace = true
|
||||
|
||||
# Internal
|
||||
artcraft_api_keys.workspace = true
|
||||
bucket_paths.workspace = true
|
||||
composite_identifiers = { path = "../../../schema/public/composite_identifiers" }
|
||||
enums = { workspace = true, features = ["database"] }
|
||||
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use artcraft_api_keys::ArtcraftApiKey;
|
||||
use tokens::tokens::api_keys::ApiKeyToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
use crate::helpers::boolean_converters::i8_to_bool;
|
||||
|
||||
pub struct GetApiKeyUserByValueArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
/// The actual API key secret presented by the client.
|
||||
pub api_key: &'e ArtcraftApiKey,
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// A live API key joined to its owning user. This is the minimal identity needed to authenticate
|
||||
/// an API-key request — far smaller than a cookie `SessionUserRecord`.
|
||||
pub struct ApiKeyUserRecord {
|
||||
pub api_key_token: ApiKeyToken,
|
||||
pub user_token: UserToken,
|
||||
pub is_banned: bool,
|
||||
}
|
||||
|
||||
/// Look up the user behind an API key secret, for authenticating an incoming request.
|
||||
///
|
||||
/// Returns `Ok(None)` when no row matches, when the matching API key is soft-deleted
|
||||
/// (`maybe_deleted_at IS NOT NULL`), or when the owning user row does not exist (the `INNER JOIN`
|
||||
/// requires both records to be present). The full secret is never echoed back.
|
||||
pub async fn get_api_key_user_by_value<'e, 'c: 'e, E>(
|
||||
args: GetApiKeyUserByValueArgs<'e, 'c, E>,
|
||||
) -> Result<Option<ApiKeyUserRecord>, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
let result = sqlx::query!(
|
||||
r#"
|
||||
SELECT
|
||||
api_keys.token as `api_key_token: ApiKeyToken`,
|
||||
users.token as `user_token: UserToken`,
|
||||
users.is_banned
|
||||
FROM api_keys
|
||||
INNER JOIN users
|
||||
ON users.token = api_keys.owner_user_token
|
||||
WHERE api_keys.api_key = ?
|
||||
AND api_keys.maybe_deleted_at IS NULL
|
||||
LIMIT 1
|
||||
"#,
|
||||
args.api_key.as_str_be_careful(),
|
||||
)
|
||||
.fetch_optional(args.mysql_executor)
|
||||
.await?;
|
||||
|
||||
Ok(result.map(|r| ApiKeyUserRecord {
|
||||
api_key_token: r.api_key_token,
|
||||
user_token: r.user_token,
|
||||
is_banned: i8_to_bool(r.is_banned),
|
||||
}))
|
||||
}
|
||||
@@ -2,6 +2,7 @@ use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use artcraft_api_keys::ArtcraftApiKey;
|
||||
use tokens::tokens::api_keys::ApiKeyToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
@@ -13,7 +14,7 @@ where
|
||||
pub ip_address: &'e str,
|
||||
pub name: &'e str,
|
||||
pub maybe_description: Option<&'e str>,
|
||||
pub api_key: &'e str,
|
||||
pub api_key: &'e ArtcraftApiKey,
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
@@ -44,7 +45,7 @@ SET
|
||||
created_at = NOW()
|
||||
"#,
|
||||
token.as_str(),
|
||||
args.api_key,
|
||||
args.api_key.as_str_be_careful(), // NB: This is okay in this case.
|
||||
args.name,
|
||||
args.maybe_description,
|
||||
args.owner_user_token.as_str(),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod api_key_row;
|
||||
pub mod delete_api_key;
|
||||
pub mod get_api_key_by_token;
|
||||
pub mod get_api_key_user_by_value;
|
||||
pub mod get_live_api_key_by_value;
|
||||
pub mod insert_api_key;
|
||||
pub mod list_api_keys_for_user;
|
||||
|
||||
@@ -19,6 +19,7 @@ pub enum UserFeatureFlag {
|
||||
/// (For now, it's hidden until we get an NSFW filter.)
|
||||
ExploreMedia,
|
||||
|
||||
|
||||
/// Access to studio features
|
||||
Studio,
|
||||
|
||||
@@ -44,6 +45,10 @@ pub enum UserFeatureFlag {
|
||||
/// Access to the referrals program
|
||||
#[serde(rename = "referrals")]
|
||||
ReferralsProgram,
|
||||
|
||||
/// Access to API key creation
|
||||
#[serde(rename = "api_key")]
|
||||
ApiKey,
|
||||
}
|
||||
|
||||
// TODO(bt, 2022-12-21): This desperately needs MySQL integration tests!
|
||||
@@ -63,6 +68,7 @@ impl UserFeatureFlag {
|
||||
Self::HappyHorse => "hh",
|
||||
Self::HappyHorseRateLimit => "hh_rl",
|
||||
Self::ReferralsProgram => "referrals",
|
||||
Self::ApiKey => "api_key",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +82,7 @@ impl UserFeatureFlag {
|
||||
"hh" => Ok(Self::HappyHorse),
|
||||
"hh_rl" => Ok(Self::HappyHorseRateLimit),
|
||||
"referrals" => Ok(Self::ReferralsProgram),
|
||||
"api_key" => Ok(Self::ApiKey),
|
||||
_ => Err(format!("invalid value: {:?}", value)),
|
||||
}
|
||||
}
|
||||
@@ -90,6 +97,7 @@ impl UserFeatureFlag {
|
||||
Self::HappyHorse => "Happy Horse (no Rate Limit)",
|
||||
Self::HappyHorseRateLimit => "Happy Horse (with Rate Limit)",
|
||||
Self::ReferralsProgram => "Referrals Program",
|
||||
Self::ApiKey => "API Keys",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +111,7 @@ impl UserFeatureFlag {
|
||||
Self::HappyHorse => "Access to Happy Horse (without a rate limit - dangerous)",
|
||||
Self::HappyHorseRateLimit => "Access to Happy Horse (with a rate limit)",
|
||||
Self::ReferralsProgram => "Access to the referrals program",
|
||||
Self::ApiKey => "Access to API key creation",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,6 +127,7 @@ impl UserFeatureFlag {
|
||||
Self::HappyHorse,
|
||||
Self::HappyHorseRateLimit,
|
||||
Self::ReferralsProgram,
|
||||
Self::ApiKey,
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -140,6 +150,7 @@ mod tests {
|
||||
assert_serialization(UserFeatureFlag::HappyHorse, "hh");
|
||||
assert_serialization(UserFeatureFlag::HappyHorseRateLimit, "hh_rl");
|
||||
assert_serialization(UserFeatureFlag::ReferralsProgram, "referrals");
|
||||
assert_serialization(UserFeatureFlag::ApiKey, "api_key");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -152,6 +163,7 @@ mod tests {
|
||||
assert_eq!(UserFeatureFlag::HappyHorse.to_str(), "hh");
|
||||
assert_eq!(UserFeatureFlag::HappyHorseRateLimit.to_str(), "hh_rl");
|
||||
assert_eq!(UserFeatureFlag::ReferralsProgram.to_str(), "referrals");
|
||||
assert_eq!(UserFeatureFlag::ApiKey.to_str(), "api_key");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -164,13 +176,14 @@ mod tests {
|
||||
assert_eq!(UserFeatureFlag::from_str("hh").unwrap(), UserFeatureFlag::HappyHorse);
|
||||
assert_eq!(UserFeatureFlag::from_str("hh_rl").unwrap(), UserFeatureFlag::HappyHorseRateLimit);
|
||||
assert_eq!(UserFeatureFlag::from_str("referrals").unwrap(), UserFeatureFlag::ReferralsProgram);
|
||||
assert_eq!(UserFeatureFlag::from_str("api_key").unwrap(), UserFeatureFlag::ApiKey);
|
||||
assert!(UserFeatureFlag::from_str("foo").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_variants() {
|
||||
let mut variants = UserFeatureFlag::all_variants();
|
||||
assert_eq!(variants.len(), 8);
|
||||
assert_eq!(variants.len(), 9);
|
||||
assert_eq!(variants.pop_first(), Some(UserFeatureFlag::ExploreMedia));
|
||||
assert_eq!(variants.pop_first(), Some(UserFeatureFlag::Studio));
|
||||
assert_eq!(variants.pop_first(), Some(UserFeatureFlag::Upload3d));
|
||||
@@ -179,6 +192,7 @@ mod tests {
|
||||
assert_eq!(variants.pop_first(), Some(UserFeatureFlag::HappyHorse));
|
||||
assert_eq!(variants.pop_first(), Some(UserFeatureFlag::HappyHorseRateLimit));
|
||||
assert_eq!(variants.pop_first(), Some(UserFeatureFlag::ReferralsProgram));
|
||||
assert_eq!(variants.pop_first(), Some(UserFeatureFlag::ApiKey));
|
||||
assert_eq!(variants.pop_first(), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,9 @@ pub enum PlatformType {
|
||||
/// Desktop app users
|
||||
DesktopApp,
|
||||
|
||||
/// Requests authenticated via an `Authorization` header API key (the Omni API surface).
|
||||
ApiKey,
|
||||
|
||||
// ========== Other clients ==========
|
||||
|
||||
Curl,
|
||||
@@ -42,6 +45,7 @@ impl PlatformType {
|
||||
match self {
|
||||
Self::Web => "web",
|
||||
Self::DesktopApp => "desktop_app",
|
||||
Self::ApiKey => "api_key",
|
||||
Self::Curl => "curl",
|
||||
Self::PythonRequests => "python_requests",
|
||||
Self::Postman => "postman",
|
||||
@@ -52,6 +56,7 @@ impl PlatformType {
|
||||
match value {
|
||||
"web" => Ok(Self::Web),
|
||||
"desktop_app" => Ok(Self::DesktopApp),
|
||||
"api_key" => Ok(Self::ApiKey),
|
||||
"curl" => Ok(Self::Curl),
|
||||
"python_requests" => Ok(Self::PythonRequests),
|
||||
"postman" => Ok(Self::Postman),
|
||||
@@ -64,6 +69,7 @@ impl PlatformType {
|
||||
BTreeSet::from([
|
||||
Self::Web,
|
||||
Self::DesktopApp,
|
||||
Self::ApiKey,
|
||||
Self::Curl,
|
||||
Self::PythonRequests,
|
||||
Self::Postman,
|
||||
@@ -84,6 +90,7 @@ mod tests {
|
||||
fn test_serialization() {
|
||||
assert_serialization(PlatformType::Web, "web");
|
||||
assert_serialization(PlatformType::DesktopApp, "desktop_app");
|
||||
assert_serialization(PlatformType::ApiKey, "api_key");
|
||||
assert_serialization(PlatformType::Curl, "curl");
|
||||
assert_serialization(PlatformType::PythonRequests, "python_requests");
|
||||
assert_serialization(PlatformType::Postman, "postman");
|
||||
@@ -93,6 +100,7 @@ mod tests {
|
||||
fn to_str() {
|
||||
assert_eq!(PlatformType::Web.to_str(), "web");
|
||||
assert_eq!(PlatformType::DesktopApp.to_str(), "desktop_app");
|
||||
assert_eq!(PlatformType::ApiKey.to_str(), "api_key");
|
||||
assert_eq!(PlatformType::Curl.to_str(), "curl");
|
||||
assert_eq!(PlatformType::PythonRequests.to_str(), "python_requests");
|
||||
assert_eq!(PlatformType::Postman.to_str(), "postman");
|
||||
@@ -102,6 +110,7 @@ mod tests {
|
||||
fn from_str() {
|
||||
assert_eq!(PlatformType::from_str("web").unwrap(), PlatformType::Web);
|
||||
assert_eq!(PlatformType::from_str("desktop_app").unwrap(), PlatformType::DesktopApp);
|
||||
assert_eq!(PlatformType::from_str("api_key").unwrap(), PlatformType::ApiKey);
|
||||
assert_eq!(PlatformType::from_str("curl").unwrap(), PlatformType::Curl);
|
||||
assert_eq!(PlatformType::from_str("python_requests").unwrap(), PlatformType::PythonRequests);
|
||||
assert_eq!(PlatformType::from_str("postman").unwrap(), PlatformType::Postman);
|
||||
@@ -121,9 +130,10 @@ mod tests {
|
||||
#[test]
|
||||
fn all_variants() {
|
||||
let mut variants = PlatformType::all_variants();
|
||||
assert_eq!(variants.len(), 5);
|
||||
assert_eq!(variants.len(), 6);
|
||||
assert_eq!(variants.pop_first(), Some(PlatformType::Web));
|
||||
assert_eq!(variants.pop_first(), Some(PlatformType::DesktopApp));
|
||||
assert_eq!(variants.pop_first(), Some(PlatformType::ApiKey));
|
||||
assert_eq!(variants.pop_first(), Some(PlatformType::Curl));
|
||||
assert_eq!(variants.pop_first(), Some(PlatformType::PythonRequests));
|
||||
assert_eq!(variants.pop_first(), Some(PlatformType::Postman));
|
||||
|
||||
@@ -36,6 +36,7 @@ actix_artcraft.workspace = true
|
||||
actix_cors_configs = { path = "../../../lib/actix_cors_configs" }
|
||||
actix_helpers.workspace = true
|
||||
artcraft_api_defs.workspace = true
|
||||
artcraft_api_keys.workspace = true
|
||||
artcraft_router.workspace = true
|
||||
bootstrap.workspace = true
|
||||
bucket_paths.workspace = true
|
||||
|
||||
@@ -80,6 +80,9 @@ use crate::http_server::endpoints::media_files::upload::upload_spz_media_file_ha
|
||||
use crate::http_server::endpoints::media_files::upload::upload_studio_shot::upload_studio_shot_media_file_handler::*;
|
||||
use crate::http_server::endpoints::media_files::upload::upload_video_new::upload_new_video_media_file_handler::*;
|
||||
use crate::http_server::endpoints::media_files::upload::upload_video_old::upload_video_media_file_handler::*;
|
||||
use crate::http_server::endpoints::omni_api::upload::omni_upload_audio_media_file_handler::*;
|
||||
use crate::http_server::endpoints::omni_api::upload::omni_upload_image_media_file_handler::*;
|
||||
use crate::http_server::endpoints::omni_api::upload::omni_upload_video_media_file_handler::*;
|
||||
use crate::http_server::endpoints::media_files::upsert_upload::write_engine_asset::write_engine_asset_media_file_handler::*;
|
||||
use crate::http_server::endpoints::media_files::upsert_upload::write_error::MediaFileWriteError;
|
||||
use crate::http_server::endpoints::media_files::upsert_upload::write_scene_file::write_scene_file_media_file_handler::*;
|
||||
@@ -343,6 +346,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::omni_upload_audio::*;
|
||||
use artcraft_api_defs::omni_api::omni_upload_image::*;
|
||||
use artcraft_api_defs::omni_api::omni_upload_video::*;
|
||||
use artcraft_api_defs::moderation::user::list_subscribing_users_by_signup_date::*;
|
||||
use artcraft_api_defs::moderation::user::list_users_by_signup_date::*;
|
||||
use artcraft_api_defs::moderation::user::user_lookup::*;
|
||||
@@ -550,6 +556,12 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
crate::http_server::endpoints::omni_gen::generate::video::omni_gen_video_generate_handler::omni_gen_video_generate_handler,
|
||||
crate::http_server::endpoints::omni_gen::models::image::omni_gen_image_models_handler::omni_gen_image_models_handler,
|
||||
crate::http_server::endpoints::omni_gen::models::video::omni_gen_video_models_handler::omni_gen_video_models_handler,
|
||||
// Omni API (API-key authenticated generate)
|
||||
crate::http_server::endpoints::omni_api::generate::image::omni_api_image_generate_handler::omni_api_image_generate_handler,
|
||||
crate::http_server::endpoints::omni_api::generate::video::omni_api_video_generate_handler::omni_api_video_generate_handler,
|
||||
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,
|
||||
// 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,
|
||||
@@ -1077,6 +1089,12 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
UploadEngineAssetMediaSuccessResponse,
|
||||
UploadImageMediaFileForm,
|
||||
UploadImageMediaFileSuccessResponse,
|
||||
OmniUploadAudioMediaFileForm,
|
||||
OmniUploadAudioMediaFileSuccessResponse,
|
||||
OmniUploadImageMediaFileForm,
|
||||
OmniUploadImageMediaFileSuccessResponse,
|
||||
OmniUploadVideoMediaFileForm,
|
||||
OmniUploadVideoMediaFileSuccessResponse,
|
||||
UserImpersonationRequestResponse,
|
||||
UploadMediaSuccessResponse,
|
||||
UploadNewEngineAssetFileForm,
|
||||
|
||||
+2
-7
@@ -89,7 +89,7 @@ pub async fn log_browser_session_handler(
|
||||
http_request: HttpRequest,
|
||||
request: web::Json<LogBrowserSessionRequest>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<HttpResponse, LogBrowserSessionError>
|
||||
) -> Result<web::Json<LogBrowserSessionSuccessResponse>, LogBrowserSessionError>
|
||||
{
|
||||
let mut mysql_connection = server_state.mysql_pool
|
||||
.acquire()
|
||||
@@ -131,11 +131,6 @@ pub async fn log_browser_session_handler(
|
||||
log_token: token,
|
||||
};
|
||||
|
||||
let body = serde_json::to_string(&response)
|
||||
.map_err(|_e| LogBrowserSessionError::ServerError)?;
|
||||
|
||||
Ok(HttpResponse::Ok()
|
||||
.content_type("application/json")
|
||||
.body(body))
|
||||
Ok(web::Json(response))
|
||||
}
|
||||
|
||||
|
||||
+13
-1
@@ -10,6 +10,7 @@ use http_server_common::request::get_request_ip::get_request_ip;
|
||||
use mysql_queries::queries::api_keys::insert_api_key::{insert_api_key, InsertApiKeyArgs};
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::session::lookup::user_session_feature_flags::UserSessionFeatureFlags;
|
||||
use crate::http_server::web_utils::user_session::require_user_session::require_user_session;
|
||||
use crate::state::server_state::ServerState;
|
||||
use crate::util::generate_api_key::generate_api_key;
|
||||
@@ -29,6 +30,7 @@ const MAX_DESCRIPTION_LEN: usize = 512;
|
||||
(status = 200, body = CreateApiKeySuccessResponse),
|
||||
(status = 400, body = CommonWebError),
|
||||
(status = 401, body = CommonWebError),
|
||||
(status = 403, body = CommonWebError),
|
||||
(status = 500, body = CommonWebError),
|
||||
),
|
||||
)]
|
||||
@@ -44,6 +46,14 @@ pub async fn create_api_key_handler(
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
|
||||
|
||||
// API key creation is gated behind the `api_key` feature flag.
|
||||
let feature_flags = UserSessionFeatureFlags::new(user_session.maybe_feature_flags.as_deref());
|
||||
|
||||
if !feature_flags.can_create_api_key() {
|
||||
warn!("User {:?} attempted to create an API key without the api_key feature flag", user_session.user_token.as_str());
|
||||
return Err(CommonWebError::Forbidden);
|
||||
}
|
||||
|
||||
let name = request.name.trim().to_string();
|
||||
|
||||
let maybe_description = request.maybe_description.as_ref().map(|s| s.trim().to_string());
|
||||
@@ -86,6 +96,8 @@ pub async fn create_api_key_handler(
|
||||
Ok(Json(CreateApiKeySuccessResponse {
|
||||
success: true,
|
||||
api_key_token: token,
|
||||
api_key,
|
||||
// Return the full secret value (never the redacted Debug/Display form). This is the only
|
||||
// time it is ever exposed.
|
||||
api_key: api_key.to_string_be_careful(), // NB: This is okay in this case
|
||||
}))
|
||||
}
|
||||
|
||||
+3
-8
@@ -3,7 +3,7 @@ use std::sync::Arc;
|
||||
|
||||
use actix_web::error::ResponseError;
|
||||
use actix_web::http::StatusCode;
|
||||
use actix_web::{web, HttpRequest, HttpResponse};
|
||||
use actix_web::{web, HttpRequest};
|
||||
use log::warn;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
@@ -58,7 +58,7 @@ pub async fn create_beta_keys_handler(
|
||||
http_request: HttpRequest,
|
||||
request: web::Json<CreateBetaKeysRequest>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<HttpResponse, CommonWebError>
|
||||
) -> Result<web::Json<CreateBetaKeysSuccessResponse>, CommonWebError>
|
||||
{
|
||||
let user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
|
||||
|
||||
@@ -106,11 +106,6 @@ pub async fn create_beta_keys_handler(
|
||||
beta_keys,
|
||||
};
|
||||
|
||||
let body = serde_json::to_string(&response)
|
||||
.map_err(CommonWebError::from_error)?;
|
||||
|
||||
Ok(HttpResponse::Ok()
|
||||
.content_type("application/json")
|
||||
.body(body))
|
||||
Ok(web::Json(response))
|
||||
}
|
||||
|
||||
|
||||
+3
-8
@@ -4,7 +4,7 @@ use std::sync::Arc;
|
||||
use actix_web::error::ResponseError;
|
||||
use actix_web::http::StatusCode;
|
||||
use actix_web::web::Path;
|
||||
use actix_web::{web, HttpRequest, HttpResponse};
|
||||
use actix_web::{web, HttpRequest};
|
||||
use log::warn;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
@@ -56,7 +56,7 @@ pub async fn edit_beta_key_distributed_flag_handler(
|
||||
request: web::Json<EditBetaKeyDistributedFlagRequest>,
|
||||
path: Path<EditBetaKeyDistributedFlagPathInfo>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<HttpResponse, CommonWebError>
|
||||
) -> Result<web::Json<EditBetaKeyDistributedFlagSuccessResponse>, CommonWebError>
|
||||
{
|
||||
let user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
|
||||
|
||||
@@ -71,10 +71,5 @@ pub async fn edit_beta_key_distributed_flag_handler(
|
||||
success: true,
|
||||
};
|
||||
|
||||
let body = serde_json::to_string(&response)
|
||||
.map_err(CommonWebError::from_error)?;
|
||||
|
||||
Ok(HttpResponse::Ok()
|
||||
.content_type("application/json")
|
||||
.body(body))
|
||||
Ok(web::Json(response))
|
||||
}
|
||||
|
||||
+3
-8
@@ -4,7 +4,7 @@ use std::sync::Arc;
|
||||
use actix_web::error::ResponseError;
|
||||
use actix_web::http::StatusCode;
|
||||
use actix_web::web::Path;
|
||||
use actix_web::{web, HttpRequest, HttpResponse};
|
||||
use actix_web::{web, HttpRequest};
|
||||
use log::warn;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
@@ -55,7 +55,7 @@ pub async fn edit_beta_key_note_handler(
|
||||
request: web::Json<EditBetaKeyNoteRequest>,
|
||||
path: Path<EditBetaKeyNotePathInfo>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<HttpResponse, CommonWebError>
|
||||
) -> Result<web::Json<EditBetaKeyNoteSuccessResponse>, CommonWebError>
|
||||
{
|
||||
let user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
|
||||
|
||||
@@ -75,10 +75,5 @@ pub async fn edit_beta_key_note_handler(
|
||||
success: true,
|
||||
};
|
||||
|
||||
let body = serde_json::to_string(&response)
|
||||
.map_err(CommonWebError::from_error)?;
|
||||
|
||||
Ok(HttpResponse::Ok()
|
||||
.content_type("application/json")
|
||||
.body(body))
|
||||
Ok(web::Json(response))
|
||||
}
|
||||
|
||||
+3
-8
@@ -3,7 +3,7 @@ use std::sync::Arc;
|
||||
use actix_web::error::ResponseError;
|
||||
use actix_web::http::StatusCode;
|
||||
use actix_web::web::Query;
|
||||
use actix_web::{web, HttpRequest, HttpResponse};
|
||||
use actix_web::{web, HttpRequest};
|
||||
use chrono::{DateTime, Utc};
|
||||
use log::warn;
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
@@ -107,7 +107,7 @@ pub async fn list_beta_keys_handler(
|
||||
http_request: HttpRequest,
|
||||
query: Query<ListBetaKeysQueryParams>,
|
||||
server_state: web::Data<Arc<ServerState>>
|
||||
) -> Result<HttpResponse, CommonWebError> {
|
||||
) -> Result<web::Json<ListBetaKeysSuccessResponse>, CommonWebError> {
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &server_state.mysql_pool)
|
||||
.await?;
|
||||
|
||||
@@ -212,10 +212,5 @@ pub async fn list_beta_keys_handler(
|
||||
}
|
||||
};
|
||||
|
||||
let body = serde_json::to_string(&response)
|
||||
.map_err(CommonWebError::from_error)?;
|
||||
|
||||
Ok(HttpResponse::Ok()
|
||||
.content_type("application/json")
|
||||
.body(body))
|
||||
Ok(web::Json(response))
|
||||
}
|
||||
|
||||
+3
-8
@@ -1,6 +1,6 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::{web, HttpRequest, HttpResponse};
|
||||
use actix_web::{web, HttpRequest};
|
||||
use log::warn;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
@@ -50,7 +50,7 @@ pub async fn redeem_beta_key_handler(
|
||||
http_request: HttpRequest,
|
||||
request: web::Json<RedeemBetaKeyRequest>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<HttpResponse, CommonWebError>
|
||||
) -> Result<web::Json<RedeemBetaKeySuccessResponse>, CommonWebError>
|
||||
{
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
|
||||
|
||||
@@ -90,12 +90,7 @@ pub async fn redeem_beta_key_handler(
|
||||
success: true,
|
||||
};
|
||||
|
||||
// `?` via the `From<serde_json::Error>` impl on CommonWebError.
|
||||
let body = serde_json::to_string(&response)?;
|
||||
|
||||
Ok(HttpResponse::Ok()
|
||||
.content_type("application/json")
|
||||
.body(body))
|
||||
Ok(web::Json(response))
|
||||
}
|
||||
|
||||
async fn enroll_in_studio(
|
||||
|
||||
+2
-7
@@ -97,7 +97,7 @@ pub async fn create_comment_handler(
|
||||
http_request: HttpRequest,
|
||||
request: web::Json<CreateCommentRequest>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<HttpResponse, CreateCommentError>
|
||||
) -> Result<web::Json<CreateCommentSuccessResponse>, CreateCommentError>
|
||||
{
|
||||
// NB(bt,2023-12-14): Kasisnu found that we're getting entity type mismatches in production. Apart from
|
||||
// querying the database for entity existence, this is the next best way to prevent incorrect comment
|
||||
@@ -188,10 +188,5 @@ pub async fn create_comment_handler(
|
||||
comment_token,
|
||||
};
|
||||
|
||||
let body = serde_json::to_string(&response)
|
||||
.map_err(|_e| CreateCommentError::ServerError)?;
|
||||
|
||||
Ok(HttpResponse::Ok()
|
||||
.content_type("application/json")
|
||||
.body(body))
|
||||
Ok(web::Json(response))
|
||||
}
|
||||
|
||||
+2
-3
@@ -19,7 +19,6 @@ use tokens::tokens::comments::CommentToken;
|
||||
|
||||
use artcraft_api_defs::common::responses::simple_generic_json_success::SimpleGenericJsonSuccess;
|
||||
use crate::http_server::web_utils::response_error_helpers::to_simple_json_error;
|
||||
use crate::http_server::web_utils::response_success_helpers::simple_json_success;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
/// For the URL PathInfo
|
||||
@@ -92,7 +91,7 @@ pub async fn delete_comment_handler(
|
||||
path: Path<DeleteCommentPathInfo>,
|
||||
request: web::Json<DeleteCommentRequest>,
|
||||
server_state: web::Data<Arc<ServerState>>
|
||||
) -> Result<HttpResponse, DeleteCommentError> {
|
||||
) -> Result<web::Json<SimpleGenericJsonSuccess>, DeleteCommentError> {
|
||||
let mut mysql_connection = server_state.mysql_pool
|
||||
.acquire()
|
||||
.await
|
||||
@@ -166,5 +165,5 @@ pub async fn delete_comment_handler(
|
||||
}
|
||||
};
|
||||
|
||||
Ok(simple_json_success())
|
||||
Ok(web::Json(SimpleGenericJsonSuccess { success: true }))
|
||||
}
|
||||
|
||||
+2
-7
@@ -129,7 +129,7 @@ pub async fn list_comments_handler(
|
||||
_http_request: HttpRequest,
|
||||
path: Path<ListCommentsPathInfo>,
|
||||
server_state: web::Data<Arc<ServerState>>
|
||||
) -> Result<HttpResponse, ListCommentsError>
|
||||
) -> Result<web::Json<ListCommentsSuccessResponse>, ListCommentsError>
|
||||
{
|
||||
let entity_token = CommentEntityToken::from_entity_type_and_token(
|
||||
path.entity_type, &path.entity_token);
|
||||
@@ -174,10 +174,5 @@ pub async fn list_comments_handler(
|
||||
.collect(),
|
||||
};
|
||||
|
||||
let body = serde_json::to_string(&response)
|
||||
.map_err(|_e| ListCommentsError::ServerError)?;
|
||||
|
||||
Ok(HttpResponse::Ok()
|
||||
.content_type("application/json")
|
||||
.body(body))
|
||||
Ok(web::Json(response))
|
||||
}
|
||||
|
||||
+2
-7
@@ -70,7 +70,7 @@ impl fmt::Display for EnqueueGenericDownloadError {
|
||||
pub async fn enqueue_generic_download_handler(
|
||||
http_request: HttpRequest,
|
||||
request: web::Json<EnqueueGenericDownloadRequest>,
|
||||
server_state: web::Data<Arc<ServerState>>) -> Result<HttpResponse, EnqueueGenericDownloadError>
|
||||
server_state: web::Data<Arc<ServerState>>) -> Result<web::Json<EnqueueGenericDownloadSuccessResponse>, EnqueueGenericDownloadError>
|
||||
{
|
||||
if let Err(_err) = server_state.redis_rate_limiters.model_upload.rate_limit_request(&http_request).await {
|
||||
return Err(EnqueueGenericDownloadError::RateLimited);
|
||||
@@ -150,10 +150,5 @@ pub async fn enqueue_generic_download_handler(
|
||||
job_token: job_token.to_string(),
|
||||
};
|
||||
|
||||
let body = serde_json::to_string(&response)
|
||||
.map_err(|e| EnqueueGenericDownloadError::ServerError)?;
|
||||
|
||||
Ok(HttpResponse::Ok()
|
||||
.content_type("application/json")
|
||||
.body(body))
|
||||
Ok(web::Json(response))
|
||||
}
|
||||
|
||||
+2
-7
@@ -87,7 +87,7 @@ impl fmt::Display for GetGenericDownloadJobStatusError {
|
||||
pub async fn get_generic_download_job_status_handler(
|
||||
http_request: HttpRequest,
|
||||
path: Path<GetGenericDownloadJobStatusPathInfo>,
|
||||
server_state: web::Data<Arc<ServerState>>) -> Result<HttpResponse, GetGenericDownloadJobStatusError>
|
||||
server_state: web::Data<Arc<ServerState>>) -> Result<web::Json<GetGenericDownloadJobStatusSuccessResponse>, GetGenericDownloadJobStatusError>
|
||||
{
|
||||
let maybe_job_status = get_generic_download_job_status(
|
||||
&path.token,
|
||||
@@ -142,10 +142,5 @@ pub async fn get_generic_download_job_status_handler(
|
||||
state: model_for_response,
|
||||
};
|
||||
|
||||
let body = serde_json::to_string(&response)
|
||||
.map_err(|e| GetGenericDownloadJobStatusError::ServerError)?;
|
||||
|
||||
Ok(HttpResponse::Ok()
|
||||
.content_type("application/json")
|
||||
.body(body))
|
||||
Ok(web::Json(response))
|
||||
}
|
||||
|
||||
+3
-8
@@ -3,7 +3,7 @@ use std::sync::Arc;
|
||||
|
||||
use actix_web::error::ResponseError;
|
||||
use actix_web::http::StatusCode;
|
||||
use actix_web::{web, HttpRequest, HttpResponse};
|
||||
use actix_web::{web, HttpRequest};
|
||||
use log::{error, warn};
|
||||
use sqlx::Acquire;
|
||||
use utoipa::ToSchema;
|
||||
@@ -52,7 +52,7 @@ pub async fn create_featured_item_handler(
|
||||
http_request: HttpRequest,
|
||||
request: web::Json<CreateFeaturedItemRequest>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<HttpResponse, CommonWebError>
|
||||
) -> Result<web::Json<CreateFeaturedItemSuccessResponse>, CommonWebError>
|
||||
{
|
||||
// NB(bt,2023-12-14): Kasisnu found that we're getting entity type mismatches in production. Apart from
|
||||
// querying the database for entity existence, this is the next best way to prevent incorrect comment
|
||||
@@ -163,10 +163,5 @@ pub async fn create_featured_item_handler(
|
||||
success: true,
|
||||
};
|
||||
|
||||
let body = serde_json::to_string(&response)
|
||||
.map_err(CommonWebError::from_error)?;
|
||||
|
||||
Ok(HttpResponse::Ok()
|
||||
.content_type("application/json")
|
||||
.body(body))
|
||||
Ok(web::Json(response))
|
||||
}
|
||||
|
||||
+3
-4
@@ -3,7 +3,7 @@ use std::sync::Arc;
|
||||
|
||||
use actix_web::error::ResponseError;
|
||||
use actix_web::http::StatusCode;
|
||||
use actix_web::{web, HttpRequest, HttpResponse};
|
||||
use actix_web::{web, HttpRequest};
|
||||
use log::{error, warn};
|
||||
use sqlx::Acquire;
|
||||
use utoipa::ToSchema;
|
||||
@@ -22,7 +22,6 @@ use tokens::tokens::users::UserToken;
|
||||
use artcraft_api_defs::common::responses::simple_generic_json_success::SimpleGenericJsonSuccess;
|
||||
use crate::http_server::web_utils::response_error_helpers::to_simple_json_error;
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::web_utils::response_success_helpers::simple_json_success;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
@@ -49,7 +48,7 @@ pub async fn delete_featured_item_handler(
|
||||
http_request: HttpRequest,
|
||||
request: web::Json<DeleteFeaturedItemRequest>,
|
||||
server_state: web::Data<Arc<ServerState>>
|
||||
) -> Result<HttpResponse, CommonWebError> {
|
||||
) -> Result<web::Json<SimpleGenericJsonSuccess>, CommonWebError> {
|
||||
|
||||
// NB(bt,2023-12-14): Kasisnu found that we're getting entity type mismatches in production. Apart from
|
||||
// querying the database for entity existence, this is the next best way to prevent incorrect comment
|
||||
@@ -138,5 +137,5 @@ pub async fn delete_featured_item_handler(
|
||||
}
|
||||
};
|
||||
|
||||
Ok(simple_json_success())
|
||||
Ok(web::Json(SimpleGenericJsonSuccess { success: true }))
|
||||
}
|
||||
|
||||
+3
-8
@@ -4,7 +4,7 @@ use std::sync::Arc;
|
||||
|
||||
use actix_web::error::ResponseError;
|
||||
use actix_web::http::StatusCode;
|
||||
use actix_web::{web, HttpRequest, HttpResponse};
|
||||
use actix_web::{web, HttpRequest};
|
||||
use log::warn;
|
||||
|
||||
use enums::by_table::generic_inference_jobs::inference_category::InferenceCategory;
|
||||
@@ -53,7 +53,7 @@ pub struct KillInferenceJobsResponse {
|
||||
pub async fn kill_generic_inference_jobs_handler(
|
||||
http_request: HttpRequest,
|
||||
request: web::Json<KillInferenceJobsRequest>,
|
||||
server_state: web::Data<Arc<ServerState>>) -> Result<HttpResponse, CommonWebError>
|
||||
server_state: web::Data<Arc<ServerState>>) -> Result<web::Json<KillInferenceJobsResponse>, CommonWebError>
|
||||
{
|
||||
let maybe_user_session = server_state
|
||||
.session_checker
|
||||
@@ -106,10 +106,5 @@ pub async fn kill_generic_inference_jobs_handler(
|
||||
success: true,
|
||||
};
|
||||
|
||||
let body = serde_json::to_string(&response)
|
||||
.map_err(CommonWebError::from_error)?;
|
||||
|
||||
Ok(HttpResponse::Ok()
|
||||
.content_type("application/json")
|
||||
.body(body))
|
||||
Ok(web::Json(response))
|
||||
}
|
||||
|
||||
+3
-11
@@ -4,7 +4,7 @@ use std::sync::Arc;
|
||||
use actix_web::error::ResponseError;
|
||||
use actix_web::http::StatusCode;
|
||||
use actix_web::web::Path;
|
||||
use actix_web::{web, HttpMessage, HttpRequest, HttpResponse};
|
||||
use actix_web::{web, HttpMessage, HttpRequest};
|
||||
use log::{error, warn};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
@@ -47,7 +47,7 @@ pub struct TerminateInferenceJobSuccessResponse {
|
||||
pub async fn terminate_inference_job_handler(
|
||||
http_request: HttpRequest,
|
||||
path: Path<TerminateInferenceJobPathInfo>,
|
||||
server_state: web::Data<Arc<ServerState>>) -> Result<HttpResponse, CommonWebError>
|
||||
server_state: web::Data<Arc<ServerState>>) -> Result<web::Json<TerminateInferenceJobSuccessResponse>, CommonWebError>
|
||||
{
|
||||
let mut mysql_connection = server_state.mysql_pool.acquire()
|
||||
.await
|
||||
@@ -105,13 +105,5 @@ pub async fn terminate_inference_job_handler(
|
||||
success: true,
|
||||
};
|
||||
|
||||
let body = serde_json::to_string(&response)
|
||||
.map_err(|e| {
|
||||
error!("error returning response: {:?}", e);
|
||||
CommonWebError::from_error(e)
|
||||
})?;
|
||||
|
||||
Ok(HttpResponse::Ok()
|
||||
.content_type("application/json")
|
||||
.body(body))
|
||||
Ok(web::Json(response))
|
||||
}
|
||||
|
||||
+4
-8
@@ -1,17 +1,13 @@
|
||||
use std::fmt;
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::error::ResponseError;
|
||||
use actix_web::http::StatusCode;
|
||||
use actix_web::web::Path;
|
||||
use actix_web::{web, HttpRequest, HttpResponse};
|
||||
use actix_web::{web, HttpRequest};
|
||||
use log::warn;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use enums::common::visibility::Visibility;
|
||||
use http_server_common::request::get_request_ip::get_request_ip;
|
||||
use http_server_common::response::response_success_helpers::{simple_json_success, SimpleGenericJsonSuccess};
|
||||
use http_server_common::response::serialize_as_json_error::serialize_as_json_error;
|
||||
use http_server_common::response::response_success_helpers::SimpleGenericJsonSuccess;
|
||||
use mysql_queries::queries::media_files::edit::update_media_file_visibility::{update_media_file_visibility, UpdateMediaFileArgs};
|
||||
use mysql_queries::queries::media_files::get::get_media_file::get_media_file;
|
||||
|
||||
@@ -48,7 +44,7 @@ pub async fn change_media_file_visibility_handler(
|
||||
http_request: HttpRequest,
|
||||
path: Path<MediaFileTokenPathInfo>,
|
||||
request: web::Json<ChangeMediaFileVisibilityRequest>,
|
||||
server_state: web::Data<Arc<ServerState>>) -> Result<HttpResponse, CommonWebError>
|
||||
server_state: web::Data<Arc<ServerState>>) -> Result<web::Json<SimpleGenericJsonSuccess>, CommonWebError>
|
||||
{
|
||||
let maybe_user_session = server_state
|
||||
.session_checker
|
||||
@@ -127,5 +123,5 @@ pub async fn change_media_file_visibility_handler(
|
||||
}
|
||||
};
|
||||
|
||||
Ok(simple_json_success())
|
||||
Ok(web::Json(SimpleGenericJsonSuccess { success: true }))
|
||||
}
|
||||
|
||||
+3
-8
@@ -1,19 +1,14 @@
|
||||
use std::fmt;
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::error::ResponseError;
|
||||
use actix_web::http::StatusCode;
|
||||
use actix_web::web::Path;
|
||||
use actix_web::{web, HttpRequest, HttpResponse};
|
||||
use actix_web::{web, HttpRequest};
|
||||
use log::warn;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::http_server::common_requests::media_file_token_path_info::MediaFileTokenPathInfo;
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use artcraft_api_defs::common::responses::simple_generic_json_success::SimpleGenericJsonSuccess;
|
||||
use crate::http_server::web_utils::response_success_helpers::simple_json_success;
|
||||
use crate::state::server_state::ServerState;
|
||||
use http_server_common::response::serialize_as_json_error::serialize_as_json_error;
|
||||
use mysql_queries::queries::media_files::edit::rename_media_file::rename_media_file;
|
||||
use mysql_queries::queries::media_files::get::get_media_file::get_media_file;
|
||||
|
||||
@@ -49,7 +44,7 @@ pub async fn rename_media_file_handler(
|
||||
path: Path<MediaFileTokenPathInfo>,
|
||||
request: web::Json<RenameMediaFileRequest>,
|
||||
server_state: web::Data<Arc<ServerState>>
|
||||
) -> Result<HttpResponse, CommonWebError>{
|
||||
) -> Result<web::Json<SimpleGenericJsonSuccess>, CommonWebError>{
|
||||
let maybe_user_session = server_state
|
||||
.session_checker
|
||||
.maybe_get_user_session(&http_request, &server_state.mysql_pool)
|
||||
@@ -104,5 +99,5 @@ pub async fn rename_media_file_handler(
|
||||
CommonWebError::from_anyhow_error(err)
|
||||
})?;
|
||||
|
||||
Ok(simple_json_success())
|
||||
Ok(web::Json(SimpleGenericJsonSuccess { success: true }))
|
||||
}
|
||||
|
||||
+3
-8
@@ -1,15 +1,11 @@
|
||||
use std::fmt;
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::error::ResponseError;
|
||||
use actix_web::http::StatusCode;
|
||||
use actix_web::web::Path;
|
||||
use actix_web::{web, HttpRequest, HttpResponse};
|
||||
use actix_web::{web, HttpRequest};
|
||||
use log::{info, warn};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use enums::by_table::media_files::media_file_type::MediaFileType;
|
||||
use http_server_common::response::serialize_as_json_error::serialize_as_json_error;
|
||||
use mysql_queries::queries::media_files::edit::set_media_file_cover_image::{set_media_file_cover_image, UpdateArgs};
|
||||
use mysql_queries::queries::media_files::get::get_media_file::get_media_file;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
@@ -17,7 +13,6 @@ use tokens::tokens::media_files::MediaFileToken;
|
||||
use crate::http_server::common_requests::media_file_token_path_info::MediaFileTokenPathInfo;
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use artcraft_api_defs::common::responses::simple_generic_json_success::SimpleGenericJsonSuccess;
|
||||
use crate::http_server::web_utils::response_success_helpers::simple_json_success;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
@@ -55,7 +50,7 @@ pub async fn set_media_file_cover_image_handler(
|
||||
path: Path<MediaFileTokenPathInfo>,
|
||||
request: web::Json<SetMediaFileCoverImageRequest>,
|
||||
server_state: web::Data<Arc<ServerState>>
|
||||
) -> Result<HttpResponse, CommonWebError>{
|
||||
) -> Result<web::Json<SimpleGenericJsonSuccess>, CommonWebError>{
|
||||
let maybe_user_session = server_state
|
||||
.session_checker
|
||||
.maybe_get_user_session(&http_request, &server_state.mysql_pool)
|
||||
@@ -157,5 +152,5 @@ pub async fn set_media_file_cover_image_handler(
|
||||
}
|
||||
};
|
||||
|
||||
Ok(simple_json_success())
|
||||
Ok(web::Json(SimpleGenericJsonSuccess { success: true }))
|
||||
}
|
||||
|
||||
+3
-8
@@ -1,16 +1,11 @@
|
||||
use std::fmt;
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::error::ResponseError;
|
||||
use actix_web::http::StatusCode;
|
||||
use actix_web::web::Path;
|
||||
use actix_web::{web, HttpRequest, HttpResponse};
|
||||
use actix_web::{web, HttpRequest};
|
||||
use log::warn;
|
||||
|
||||
use enums::common::visibility::Visibility;
|
||||
use http_server_common::request::get_request_ip::get_request_ip;
|
||||
use http_server_common::response::response_success_helpers::simple_json_success;
|
||||
use http_server_common::response::serialize_as_json_error::serialize_as_json_error;
|
||||
use mysql_queries::queries::media_files::edit::update_media_file_visibility::{update_media_file_visibility, UpdateMediaFileArgs};
|
||||
use mysql_queries::queries::media_files::get::get_media_file::get_media_file;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
@@ -42,7 +37,7 @@ pub async fn update_media_file_handler(
|
||||
http_request: HttpRequest,
|
||||
path: Path<UpdateMediaFilePathInfo>,
|
||||
request: web::Json<UpdateMediaFileRequest>,
|
||||
server_state: web::Data<Arc<ServerState>>) -> Result<HttpResponse, CommonWebError>
|
||||
server_state: web::Data<Arc<ServerState>>) -> Result<web::Json<UpdateMediaFileResponse>, CommonWebError>
|
||||
{
|
||||
let maybe_user_session = server_state
|
||||
.session_checker
|
||||
@@ -121,5 +116,5 @@ pub async fn update_media_file_handler(
|
||||
}
|
||||
};
|
||||
|
||||
Ok(simple_json_success())
|
||||
Ok(web::Json(UpdateMediaFileResponse { success: true }))
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ pub mod media_files;
|
||||
pub mod misc;
|
||||
pub mod model_download;
|
||||
pub mod moderation;
|
||||
pub mod omni_api;
|
||||
pub mod omni_gen;
|
||||
pub mod prompts;
|
||||
pub mod service;
|
||||
|
||||
+3
-8
@@ -4,7 +4,7 @@ use std::sync::Arc;
|
||||
use actix_web::error::ResponseError;
|
||||
use actix_web::http::StatusCode;
|
||||
use actix_web::web::Path;
|
||||
use actix_web::{web, HttpRequest, HttpResponse};
|
||||
use actix_web::{web, HttpRequest};
|
||||
use log::warn;
|
||||
use serde::Serialize;
|
||||
use sqlx::MySqlPool;
|
||||
@@ -58,7 +58,7 @@ pub async fn moderator_get_token_info_handler(
|
||||
path: Path<ModeratorTokenInfoPath>,
|
||||
http_request: HttpRequest,
|
||||
server_state: web::Data<Arc<ServerState>>
|
||||
) -> Result<HttpResponse, CommonWebError> {
|
||||
) -> Result<web::Json<ModeratorTokenInfoResponse>, CommonWebError> {
|
||||
|
||||
let user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
|
||||
|
||||
@@ -75,12 +75,7 @@ pub async fn moderator_get_token_info_handler(
|
||||
maybe_payload: maybe_result,
|
||||
};
|
||||
|
||||
let body = serde_json::to_string(&response)
|
||||
.map_err(CommonWebError::from_error)?;
|
||||
|
||||
Ok(HttpResponse::Ok()
|
||||
.content_type("application/json")
|
||||
.body(body))
|
||||
Ok(web::Json(response))
|
||||
}
|
||||
|
||||
const LEGACY_USER_TOKEN_PREFIX : &str = "U:";
|
||||
|
||||
+4
-4
@@ -3,15 +3,15 @@ use std::sync::Arc;
|
||||
|
||||
use actix_web::error::ResponseError;
|
||||
use actix_web::http::StatusCode;
|
||||
use actix_web::{web, HttpRequest, HttpResponse};
|
||||
use actix_web::{web, HttpRequest};
|
||||
use log::{info, warn};
|
||||
|
||||
use artcraft_api_defs::common::responses::simple_generic_json_success::SimpleGenericJsonSuccess;
|
||||
use mysql_queries::queries::ip_bans::upsert_ip_ban::{upsert_ip_ban, UpsertIpBanArgs};
|
||||
use user_input_common::validate_user_provided_ip_address::validate_user_provided_ip_address;
|
||||
|
||||
use crate::http_server::web_utils::response_error_helpers::to_simple_json_error;
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::web_utils::response_success_helpers::simple_json_success;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -25,7 +25,7 @@ pub async fn add_ip_ban_handler(
|
||||
http_request: HttpRequest,
|
||||
request: web::Json<AddIpBanRequest>,
|
||||
server_state: web::Data<Arc<ServerState>>
|
||||
) -> Result<HttpResponse, CommonWebError> {
|
||||
) -> Result<web::Json<SimpleGenericJsonSuccess>, CommonWebError> {
|
||||
|
||||
let maybe_user_session = server_state
|
||||
.session_checker
|
||||
@@ -70,5 +70,5 @@ pub async fn add_ip_ban_handler(
|
||||
CommonWebError::from_anyhow_error(err)
|
||||
})?;
|
||||
|
||||
Ok(simple_json_success())
|
||||
Ok(web::Json(SimpleGenericJsonSuccess { success: true }))
|
||||
}
|
||||
|
||||
+4
-4
@@ -3,15 +3,15 @@ use std::sync::Arc;
|
||||
use actix_web::error::ResponseError;
|
||||
use actix_web::http::StatusCode;
|
||||
use actix_web::web::Path;
|
||||
use actix_web::{web, HttpRequest, HttpResponse};
|
||||
use actix_web::{web, HttpRequest};
|
||||
use derive_more::Display;
|
||||
use log::{info, warn};
|
||||
|
||||
use artcraft_api_defs::common::responses::simple_generic_json_success::SimpleGenericJsonSuccess;
|
||||
use mysql_queries::queries::ip_bans::toggle_ip_ban::{toggle_ip_ban, IpBanToggleState};
|
||||
|
||||
use crate::http_server::web_utils::response_error_helpers::to_simple_json_error;
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::web_utils::response_success_helpers::simple_json_success;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
/// For the URL PathInfo
|
||||
@@ -29,7 +29,7 @@ pub async fn delete_ip_ban_handler(
|
||||
path: Path<DeleteIpBanPathInfo>,
|
||||
request: web::Json<DeleteIpBanRequest>,
|
||||
server_state: web::Data<Arc<ServerState>>
|
||||
) -> Result<HttpResponse, CommonWebError> {
|
||||
) -> Result<web::Json<SimpleGenericJsonSuccess>, CommonWebError> {
|
||||
|
||||
let maybe_user_session = server_state
|
||||
.session_checker
|
||||
@@ -77,5 +77,5 @@ pub async fn delete_ip_ban_handler(
|
||||
}
|
||||
};
|
||||
|
||||
Ok(simple_json_success())
|
||||
Ok(web::Json(SimpleGenericJsonSuccess { success: true }))
|
||||
}
|
||||
|
||||
+3
-8
@@ -3,7 +3,7 @@ use std::sync::Arc;
|
||||
use actix_web::error::ResponseError;
|
||||
use actix_web::http::StatusCode;
|
||||
use actix_web::web::Path;
|
||||
use actix_web::{web, HttpRequest, HttpResponse};
|
||||
use actix_web::{web, HttpRequest};
|
||||
use chrono::{DateTime, Utc};
|
||||
use derive_more::Display;
|
||||
use log::{error, warn};
|
||||
@@ -42,7 +42,7 @@ pub async fn get_ip_ban_handler(
|
||||
http_request: HttpRequest,
|
||||
path: Path<GetIpBanPathInfo>,
|
||||
server_state: web::Data<Arc<ServerState>>
|
||||
) -> Result<HttpResponse, CommonWebError> {
|
||||
) -> Result<web::Json<GetIpBanResponse>, CommonWebError> {
|
||||
|
||||
let maybe_user_session = server_state
|
||||
.session_checker
|
||||
@@ -97,10 +97,5 @@ pub async fn get_ip_ban_handler(
|
||||
ip_address_ban: result,
|
||||
};
|
||||
|
||||
let body = serde_json::to_string(&response)
|
||||
.map_err(CommonWebError::from_error)?;
|
||||
|
||||
Ok(HttpResponse::Ok()
|
||||
.content_type("application/json")
|
||||
.body(body))
|
||||
Ok(web::Json(response))
|
||||
}
|
||||
|
||||
+3
-8
@@ -2,7 +2,7 @@ use std::sync::Arc;
|
||||
|
||||
use actix_web::error::ResponseError;
|
||||
use actix_web::http::StatusCode;
|
||||
use actix_web::{web, HttpRequest, HttpResponse};
|
||||
use actix_web::{web, HttpRequest};
|
||||
use chrono::{DateTime, Utc};
|
||||
use log::{error, warn};
|
||||
|
||||
@@ -34,7 +34,7 @@ pub struct IpBanRecordForList {
|
||||
pub async fn list_ip_bans_handler(
|
||||
http_request: HttpRequest,
|
||||
server_state: web::Data<Arc<ServerState>>
|
||||
) -> Result<HttpResponse, CommonWebError> {
|
||||
) -> Result<web::Json<ListIpBansResponse>, CommonWebError> {
|
||||
|
||||
let maybe_user_session = server_state
|
||||
.session_checker
|
||||
@@ -85,10 +85,5 @@ pub async fn list_ip_bans_handler(
|
||||
ip_address_bans: results,
|
||||
};
|
||||
|
||||
let body = serde_json::to_string(&response)
|
||||
.map_err(CommonWebError::from_error)?;
|
||||
|
||||
Ok(HttpResponse::Ok()
|
||||
.content_type("application/json")
|
||||
.body(body))
|
||||
Ok(web::Json(response))
|
||||
}
|
||||
|
||||
+3
-8
@@ -3,7 +3,7 @@ use std::sync::Arc;
|
||||
|
||||
use actix_web::error::ResponseError;
|
||||
use actix_web::http::StatusCode;
|
||||
use actix_web::{web, HttpRequest, HttpResponse};
|
||||
use actix_web::{web, HttpRequest};
|
||||
use log::warn;
|
||||
|
||||
use mysql_queries::queries::tts::tts_inference_jobs::get_pending_tts_inference_job_detailed_stats::{get_pending_tts_inference_job_detailed_stats, PendingCountResult};
|
||||
@@ -34,7 +34,7 @@ pub struct GetTtsInferenceQueueCountResponse {
|
||||
pub async fn get_tts_inference_queue_count_handler(
|
||||
http_request: HttpRequest,
|
||||
server_state: web::Data<Arc<ServerState>>
|
||||
) -> Result<HttpResponse, CommonWebError> {
|
||||
) -> Result<web::Json<GetTtsInferenceQueueCountResponse>, CommonWebError> {
|
||||
|
||||
let maybe_user_session = server_state
|
||||
.session_checker
|
||||
@@ -85,10 +85,5 @@ pub async fn get_tts_inference_queue_count_handler(
|
||||
attempt_failed_count: result.attempt_failed_count,
|
||||
};
|
||||
|
||||
let body = serde_json::to_string(&response)
|
||||
.map_err(CommonWebError::from_error)?;
|
||||
|
||||
Ok(HttpResponse::Ok()
|
||||
.content_type("application/json")
|
||||
.body(body))
|
||||
Ok(web::Json(response))
|
||||
}
|
||||
|
||||
+2
-7
@@ -47,7 +47,7 @@ impl fmt::Display for GetW2lInferenceQueueCountError {
|
||||
pub async fn get_w2l_inference_queue_count_handler(
|
||||
http_request: HttpRequest,
|
||||
server_state: web::Data<Arc<ServerState>>
|
||||
) -> Result<HttpResponse, GetW2lInferenceQueueCountError> {
|
||||
) -> Result<web::Json<GetW2lInferenceQueueCountResponse>, GetW2lInferenceQueueCountError> {
|
||||
|
||||
let maybe_user_session = server_state
|
||||
.session_checker
|
||||
@@ -92,10 +92,5 @@ pub async fn get_w2l_inference_queue_count_handler(
|
||||
seconds_since_first: result.seconds_since_first,
|
||||
};
|
||||
|
||||
let body = serde_json::to_string(&response)
|
||||
.map_err(|e| GetW2lInferenceQueueCountError::ServerError)?;
|
||||
|
||||
Ok(HttpResponse::Ok()
|
||||
.content_type("application/json")
|
||||
.body(body))
|
||||
Ok(web::Json(response))
|
||||
}
|
||||
|
||||
+3
-8
@@ -3,7 +3,7 @@ use std::sync::Arc;
|
||||
|
||||
use actix_web::error::ResponseError;
|
||||
use actix_web::http::StatusCode;
|
||||
use actix_web::{web, HttpRequest, HttpResponse};
|
||||
use actix_web::{web, HttpRequest};
|
||||
use log::{error, warn};
|
||||
|
||||
use http_server_common::response::serialize_as_json_error::serialize_as_json_error;
|
||||
@@ -46,7 +46,7 @@ pub struct KillTtsInferenceJobsResponse {
|
||||
pub async fn kill_tts_inference_jobs_handler(
|
||||
http_request: HttpRequest,
|
||||
request: web::Json<KillTtsInferenceJobsRequest>,
|
||||
server_state: web::Data<Arc<ServerState>>) -> Result<HttpResponse, CommonWebError>
|
||||
server_state: web::Data<Arc<ServerState>>) -> Result<web::Json<KillTtsInferenceJobsResponse>, CommonWebError>
|
||||
{
|
||||
let maybe_user_session = server_state
|
||||
.session_checker
|
||||
@@ -88,10 +88,5 @@ pub async fn kill_tts_inference_jobs_handler(
|
||||
success: true,
|
||||
};
|
||||
|
||||
let body = serde_json::to_string(&response)
|
||||
.map_err(CommonWebError::from_error)?;
|
||||
|
||||
Ok(HttpResponse::Ok()
|
||||
.content_type("application/json")
|
||||
.body(body))
|
||||
Ok(web::Json(response))
|
||||
}
|
||||
|
||||
+4
-5
@@ -6,8 +6,8 @@ use std::sync::Arc;
|
||||
|
||||
use actix_web::error::ResponseError;
|
||||
use actix_web::http::StatusCode;
|
||||
use actix_web::web::{Data, Json, Path};
|
||||
use actix_web::{HttpRequest, HttpResponse};
|
||||
use actix_web::web::{self, Data, Json, Path};
|
||||
use actix_web::HttpRequest;
|
||||
use log::warn;
|
||||
use redis::{Client, Commands};
|
||||
use sqlx::Acquire;
|
||||
@@ -26,7 +26,6 @@ use tokens::tokens::users::UserToken;
|
||||
use crate::http_server::session::lookup::user_session_feature_flags::UserSessionFeatureFlags;
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::web_utils::response_error_helpers::to_simple_json_error;
|
||||
use crate::http_server::web_utils::response_success_helpers::simple_json_success;
|
||||
use crate::http_server::web_utils::user_session::require_moderator::require_moderator;
|
||||
use crate::state::server_state::ServerState;
|
||||
use artcraft_api_defs::common::responses::simple_generic_json_success::SimpleGenericJsonSuccess;
|
||||
@@ -85,7 +84,7 @@ pub async fn moderator_edit_user_feature_flags_handler(
|
||||
request: Json<EditUserFeatureFlagsRequest>,
|
||||
server_state: Data<Arc<ServerState>>,
|
||||
redis_pool: Data<r2d2::Pool<Client>>,
|
||||
) -> Result<HttpResponse, CommonWebError> {
|
||||
) -> Result<web::Json<SimpleGenericJsonSuccess>, CommonWebError> {
|
||||
|
||||
let user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
|
||||
|
||||
@@ -190,5 +189,5 @@ pub async fn moderator_edit_user_feature_flags_handler(
|
||||
let _r: Result<Option<String>, _> = redis.del(&cache_key);
|
||||
}
|
||||
|
||||
Ok(simple_json_success())
|
||||
Ok(web::Json(SimpleGenericJsonSuccess { success: true }))
|
||||
}
|
||||
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest;
|
||||
use artcraft_router::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use artcraft_router::api::router_image_model::RouterImageModel;
|
||||
use artcraft_router::api::router_quality::RouterQuality;
|
||||
use artcraft_router::api::router_resolution::RouterResolution;
|
||||
use artcraft_router::api::image_list_ref::ImageListRef;
|
||||
use artcraft_router::api::router_provider::RouterProvider;
|
||||
use artcraft_router::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
use artcraft_router::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
|
||||
use enums::common::generation::common_aspect_ratio::CommonAspectRatio as CommonAspectRatioEnum;
|
||||
use enums::common::generation::common_image_model::CommonImageModel as CommonImageModelEnum;
|
||||
use enums::common::generation::common_quality::CommonQuality as CommonQualityEnum;
|
||||
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
|
||||
pub fn hydrate_to_router_request(
|
||||
request: &OmniGenImageCostAndGenerateRequest,
|
||||
) -> Result<GenerateImageRequestBuilder, CommonWebError> {
|
||||
let api_model = request.model.as_ref()
|
||||
.ok_or_else(|| CommonWebError::BadInputWithSimpleMessage(
|
||||
"model is required".to_string(),
|
||||
))?;
|
||||
|
||||
let model = convert_model(api_model)?;
|
||||
|
||||
let aspect_ratio = request.aspect_ratio.as_ref()
|
||||
.map(convert_aspect_ratio)
|
||||
.transpose()?;
|
||||
|
||||
let resolution = request.resolution.as_ref()
|
||||
.map(convert_resolution)
|
||||
.transpose()?;
|
||||
|
||||
let quality = request.quality.as_ref()
|
||||
.map(convert_quality)
|
||||
.transpose()?;
|
||||
|
||||
Ok(GenerateImageRequestBuilder {
|
||||
model,
|
||||
provider: RouterProvider::Artcraft,
|
||||
prompt: request.prompt.clone(),
|
||||
image_inputs: request.image_media_tokens.clone()
|
||||
.map(ImageListRef::MediaFileTokens),
|
||||
resolution,
|
||||
aspect_ratio,
|
||||
quality,
|
||||
image_batch_count: request.image_batch_count,
|
||||
horizontal_angle: request.adjust_horizontal_angle,
|
||||
vertical_angle: request.adjust_vertical_angle,
|
||||
zoom: request.adjust_zoom,
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
|
||||
generation_mode_mismatch_strategy: None,
|
||||
idempotency_token: request.idempotency_token.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn convert_model(
|
||||
model: &CommonImageModelEnum,
|
||||
) -> Result<RouterImageModel, CommonWebError> {
|
||||
let json = serde_json::to_string(model)?;
|
||||
serde_json::from_str(&json).map_err(|e| {
|
||||
CommonWebError::BadInputWithSimpleMessage(
|
||||
format!("Unsupported image model: {}", e),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn convert_aspect_ratio(
|
||||
ar: &CommonAspectRatioEnum,
|
||||
) -> Result<RouterAspectRatio, CommonWebError> {
|
||||
let json = serde_json::to_string(ar)?;
|
||||
serde_json::from_str(&json).map_err(|e| {
|
||||
CommonWebError::BadInputWithSimpleMessage(
|
||||
format!("Unsupported aspect ratio: {}", e),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn convert_resolution(
|
||||
res: &CommonResolutionEnum,
|
||||
) -> Result<RouterResolution, CommonWebError> {
|
||||
let json = serde_json::to_string(res)?;
|
||||
serde_json::from_str(&json).map_err(|e| {
|
||||
CommonWebError::BadInputWithSimpleMessage(
|
||||
format!("Unsupported resolution: {}", e),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn convert_quality(
|
||||
quality: &CommonQualityEnum,
|
||||
) -> Result<RouterQuality, CommonWebError> {
|
||||
let json = serde_json::to_string(quality)?;
|
||||
serde_json::from_str(&json).map_err(|e| {
|
||||
CommonWebError::BadInputWithSimpleMessage(
|
||||
format!("Unsupported quality: {}", e),
|
||||
)
|
||||
})
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
use log::warn;
|
||||
|
||||
use enums::common::visibility::Visibility;
|
||||
use mysql_queries::queries::generic_inference::api_providers::fal::insert_generic_inference_job_for_fal_queue::FalCategory;
|
||||
use mysql_queries::queries::generic_inference::api_providers::fal::insert_generic_inference_job_for_fal_queue_with_apriori_job_token::{
|
||||
insert_generic_inference_job_for_fal_queue_with_apriori_job_token,
|
||||
InsertGenericInferenceForFalWithAprioriJobTokenArgs,
|
||||
};
|
||||
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
|
||||
use super::shared_job_args::SharedJobArgs;
|
||||
|
||||
pub struct InsertFalJobArgs<'a, 'tx> {
|
||||
pub external_job_id: &'a str,
|
||||
pub shared: SharedJobArgs<'a, 'tx>,
|
||||
}
|
||||
|
||||
/// Insert a single Fal-queue inference job row for an image generation.
|
||||
/// Mirrors `omni_gen::generate::video::insert_db_job::insert_fal_job::insert_fal_job`
|
||||
/// but tags the row with `FalCategory::ImageGeneration` so the worker
|
||||
/// routes it to the image-generation queue.
|
||||
pub async fn insert_fal_job(args: InsertFalJobArgs<'_, '_>) -> Result<InferenceJobToken, CommonWebError> {
|
||||
let InsertFalJobArgs {
|
||||
external_job_id,
|
||||
shared,
|
||||
} = args;
|
||||
|
||||
let db_result = insert_generic_inference_job_for_fal_queue_with_apriori_job_token(
|
||||
InsertGenericInferenceForFalWithAprioriJobTokenArgs {
|
||||
apriori_job_token: shared.apriori_job_token,
|
||||
uuid_idempotency_token: shared.idempotency_token,
|
||||
maybe_external_third_party_id: external_job_id,
|
||||
fal_category: FalCategory::ImageGeneration,
|
||||
maybe_model_type: shared.maybe_model_type,
|
||||
maybe_prompt_token: shared.maybe_prompt_token,
|
||||
maybe_creator_user_token: Some(shared.user_token),
|
||||
maybe_avt_token: shared.maybe_avt_token,
|
||||
creator_ip_address: shared.ip_address,
|
||||
creator_set_visibility: Visibility::Public,
|
||||
maybe_platform_type: shared.maybe_platform_type,
|
||||
maybe_cost_estimates: None,
|
||||
maybe_debug_log_event_token: shared.maybe_debug_log_event_token,
|
||||
mysql_executor: &mut **shared.transaction,
|
||||
maybe_inference_args: None,
|
||||
starting_job_status_override: None,
|
||||
maybe_frontend_failure_category: None,
|
||||
maybe_failure_reason: None,
|
||||
phantom: Default::default(),
|
||||
}
|
||||
).await;
|
||||
|
||||
match db_result {
|
||||
Ok(token) => Ok(token),
|
||||
Err(err) => {
|
||||
warn!("Error inserting fal inference job (image): {:?}", err);
|
||||
Err(CommonWebError::from_error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
use log::{error, warn};
|
||||
|
||||
use enums::common::visibility::Visibility;
|
||||
use mysql_queries::queries::generic_inference::api_providers::seedance2pro::insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token::{
|
||||
insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token,
|
||||
InsertGenericInferenceForSeedance2ProWithAprioriJobTokenArgs, KinoviVersion,
|
||||
};
|
||||
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
|
||||
use tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken;
|
||||
use uuid_utils::uuid::generate_random_uuid;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
|
||||
use super::shared_job_args::SharedJobArgs;
|
||||
|
||||
pub struct InsertSeedance2proJobsArgs<'a, 'tx> {
|
||||
/// The order_id for the primary job (always inserted first).
|
||||
pub primary_order_id: &'a str,
|
||||
|
||||
/// Additional order_ids returned by Kinovi when batch_count > 1. Each
|
||||
/// becomes its own inference job row.
|
||||
pub maybe_additional_order_ids: Option<&'a [String]>,
|
||||
|
||||
/// Wallet ledger entry from the up-front deduction. Stamped on each
|
||||
/// inserted row so the worker can issue a refund on failure.
|
||||
pub maybe_wallet_ledger_entry_token: Option<&'a WalletLedgerEntryToken>,
|
||||
|
||||
/// Which Kinovi account fulfilled the request — Volcengine, BytePlus,
|
||||
/// or BytePlusUltra. For Midjourney image generation today this is
|
||||
/// always Volcengine, but the helper stays version-agnostic so a future
|
||||
/// account-picking layer can use it unchanged.
|
||||
pub kinovi_version: KinoviVersion,
|
||||
|
||||
pub shared: SharedJobArgs<'a, 'tx>,
|
||||
}
|
||||
|
||||
pub struct InsertSeedance2proJobsResult {
|
||||
pub primary_job_token: InferenceJobToken,
|
||||
pub all_job_tokens: Vec<InferenceJobToken>,
|
||||
}
|
||||
|
||||
/// Insert one Seedance2Pro/Kinovi inference job per returned order_id.
|
||||
/// Mirrors the video-side helper at
|
||||
/// `omni_gen::generate::video::insert_db_job::insert_seedance2pro_jobs`.
|
||||
pub async fn insert_seedance2pro_jobs(args: InsertSeedance2proJobsArgs<'_, '_>) -> Result<InsertSeedance2proJobsResult, CommonWebError> {
|
||||
let InsertSeedance2proJobsArgs {
|
||||
primary_order_id,
|
||||
maybe_additional_order_ids,
|
||||
maybe_wallet_ledger_entry_token,
|
||||
kinovi_version,
|
||||
mut shared,
|
||||
} = args;
|
||||
|
||||
// Build a deduplicated list with primary_order_id first.
|
||||
let mut all_order_ids = vec![primary_order_id.to_string()];
|
||||
|
||||
if let Some(additional) = maybe_additional_order_ids {
|
||||
for id in additional {
|
||||
if id != primary_order_id {
|
||||
all_order_ids.push(id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut all_job_tokens: Vec<InferenceJobToken> = Vec::with_capacity(all_order_ids.len());
|
||||
|
||||
for (i, order_id) in all_order_ids.iter().enumerate() {
|
||||
let job_token = if i == 0 { shared.apriori_job_token.clone() } else { InferenceJobToken::generate() };
|
||||
|
||||
// The idempotency_token column has a max length too tight for the
|
||||
// `{idempotency}-batch-{i}` suffix pattern used elsewhere; mint a
|
||||
// fresh UUID for each additional row instead. The primary row still
|
||||
// wants a deterministic value, but historic behavior on the video
|
||||
// side has been to mint UUIDs for all rows, which we mirror here.
|
||||
let idempotency_str = generate_random_uuid();
|
||||
|
||||
let db_result = insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token(
|
||||
InsertGenericInferenceForSeedance2ProWithAprioriJobTokenArgs {
|
||||
kinovi_version,
|
||||
apriori_job_token: &job_token,
|
||||
uuid_idempotency_token: &idempotency_str,
|
||||
maybe_external_third_party_id: order_id,
|
||||
maybe_model_type: shared.maybe_model_type,
|
||||
maybe_prompt_token: shared.maybe_prompt_token,
|
||||
maybe_wallet_ledger_entry_token,
|
||||
maybe_creator_user_token: Some(shared.user_token),
|
||||
maybe_avt_token: shared.maybe_avt_token,
|
||||
creator_ip_address: shared.ip_address,
|
||||
creator_set_visibility: Visibility::Public,
|
||||
maybe_platform_type: shared.maybe_platform_type,
|
||||
maybe_cost_estimates: None,
|
||||
maybe_debug_log_event_token: shared.maybe_debug_log_event_token,
|
||||
mysql_executor: &mut **shared.transaction,
|
||||
maybe_inference_args: None,
|
||||
phantom: Default::default(),
|
||||
}
|
||||
).await;
|
||||
|
||||
match db_result {
|
||||
Ok(token) => all_job_tokens.push(token),
|
||||
Err(err) => {
|
||||
warn!("Error inserting seedance2pro inference job (image, order_id={}): {:?}", order_id, err);
|
||||
if i == 0 { return Err(CommonWebError::from_error(err)); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let primary_job_token = all_job_tokens.first().cloned().ok_or_else(|| {
|
||||
error!("No inference job token was created");
|
||||
CommonWebError::server_error_with_message("No inference job token was created")
|
||||
})?;
|
||||
|
||||
Ok(InsertSeedance2proJobsResult {
|
||||
primary_job_token,
|
||||
all_job_tokens,
|
||||
})
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod insert_fal_job;
|
||||
pub mod insert_seedance2pro_jobs;
|
||||
pub mod shared_job_args;
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
use enums::common::generation::common_model_type::CommonModelType;
|
||||
use enums::common::platform_type::PlatformType;
|
||||
use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken;
|
||||
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
|
||||
use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken;
|
||||
use tokens::tokens::prompts::PromptToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
/// Fields every image-side `insert_*_job` helper needs.
|
||||
/// Parallels `omni_gen::generate::video::insert_db_job::shared_job_args::SharedJobArgs`.
|
||||
pub struct SharedJobArgs<'a, 'tx> {
|
||||
pub apriori_job_token: &'a InferenceJobToken,
|
||||
pub idempotency_token: &'a str,
|
||||
pub user_token: &'a UserToken,
|
||||
pub maybe_avt_token: Option<&'a AnonymousVisitorTrackingToken>,
|
||||
pub maybe_prompt_token: Option<&'a PromptToken>,
|
||||
pub maybe_debug_log_event_token: Option<&'a DebugLogEventToken>,
|
||||
pub maybe_model_type: Option<CommonModelType>,
|
||||
pub maybe_platform_type: Option<PlatformType>,
|
||||
pub ip_address: &'a str,
|
||||
pub transaction: &'a mut sqlx::Transaction<'tx, sqlx::MySql>,
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
pub mod omni_api_image_generate_handler;
|
||||
pub(crate) mod hydrate_to_router_request;
|
||||
pub(crate) mod insert_db_job;
|
||||
pub(crate) mod pipeline_result;
|
||||
pub mod pipeline_v2;
|
||||
+310
@@ -0,0 +1,310 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::web::Json;
|
||||
use actix_web::{web, HttpRequest};
|
||||
use log::{error, info, warn};
|
||||
use sqlx::Acquire;
|
||||
|
||||
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest;
|
||||
use artcraft_api_defs::omni_gen::generate_response::omni_gen_image_generate_response::OmniGenImageGenerateResponse;
|
||||
use artcraft_router::generate::generate_image::generate_image_response::GenerateImageResponse;
|
||||
use enums::by_table::debug_logs::debug_log_type::DebugLogType;
|
||||
use enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptContextSemanticType;
|
||||
use enums::by_table::prompts::prompt_type::PromptType;
|
||||
use enums::common::generation::common_generation_mode::CommonGenerationMode;
|
||||
use enums::common::generation::common_model_type::CommonModelType;
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
use enums::common::platform_type::PlatformType;
|
||||
use http_server_common::request::get_request_ip::get_request_ip;
|
||||
use mysql_queries::queries::debug_logs::insert_debug_log::{insert_debug_log, InsertDebugLogArgs};
|
||||
use mysql_queries::queries::generic_inference::api_providers::seedance2pro::insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token::KinoviVersion;
|
||||
use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_idempotency_token;
|
||||
use mysql_queries::queries::prompt_context_items::insert_batch_prompt_context_items::{
|
||||
insert_batch_prompt_context_items, InsertBatchArgs, PromptContextItem,
|
||||
};
|
||||
use mysql_queries::queries::prompts::insert_prompt::{insert_prompt, InsertPromptArgs};
|
||||
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
|
||||
use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::endpoints::generate::common::payments_error_test::payments_error_test;
|
||||
use crate::http_server::endpoints::omni_api::generate::image::hydrate_to_router_request::hydrate_to_router_request;
|
||||
use crate::http_server::endpoints::omni_api::generate::image::insert_db_job::insert_fal_job::{insert_fal_job, InsertFalJobArgs};
|
||||
use crate::http_server::endpoints::omni_api::generate::image::insert_db_job::insert_seedance2pro_jobs::{insert_seedance2pro_jobs, InsertSeedance2proJobsArgs};
|
||||
use crate::http_server::endpoints::omni_api::generate::image::insert_db_job::shared_job_args::SharedJobArgs;
|
||||
use crate::http_server::endpoints::omni_api::generate::image::pipeline_v2::run_pipeline_v2::{run_pipeline_v2, RunPipelineV2Args};
|
||||
use crate::http_server::session::lookup::user_session_feature_flags::UserSessionFeatureFlags;
|
||||
use crate::http_server::user_lookup::api_keys::require_api_key_user::require_api_key_user;
|
||||
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
|
||||
use crate::state::server_state::ServerState;
|
||||
use crate::util::lookup::lookup_media_files_as_cdn_url_list_and_map::lookup_media_files_as_cdn_url_list_and_map;
|
||||
|
||||
/// Generate an image using the omni-api unified endpoint (API-key authenticated).
|
||||
#[utoipa::path(
|
||||
post,
|
||||
tag = "Omni API",
|
||||
path = "/v1/omni_api/generate/image",
|
||||
request_body = OmniGenImageCostAndGenerateRequest,
|
||||
responses(
|
||||
(status = 200, description = "Success", body = OmniGenImageGenerateResponse),
|
||||
(status = 400, description = "Bad input"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 402, description = "Payment required"),
|
||||
(status = 500, description = "Server error"),
|
||||
),
|
||||
)]
|
||||
pub async fn omni_api_image_generate_handler(
|
||||
http_request: HttpRequest,
|
||||
request: Json<OmniGenImageCostAndGenerateRequest>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<OmniGenImageGenerateResponse>, CommonWebError> {
|
||||
|
||||
info!("request: {:?}", request);
|
||||
|
||||
payments_error_test(&request.prompt.as_deref().unwrap_or(""))?;
|
||||
|
||||
let debug_log_event_token = DebugLogEventToken::generate();
|
||||
|
||||
let maybe_prompt_model_type: Option<CommonModelType> = request.model
|
||||
.as_ref()
|
||||
.map(|m| m.to_common_model_type());
|
||||
|
||||
// ==================== API KEY USER ==================== //
|
||||
|
||||
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
|
||||
|
||||
// API-key authentication (Authorization header) instead of a session cookie. Never cached.
|
||||
let api_session = require_api_key_user(&http_request, &mut *mysql_connection).await?;
|
||||
|
||||
let user_token = &api_session.user_token;
|
||||
|
||||
// ==================== MODEL ACCESS CHECK ==================== //
|
||||
|
||||
// API-key users have no per-user feature flags today.
|
||||
let user_feature_flags = UserSessionFeatureFlags::new(None);
|
||||
|
||||
// ==================== IDEMPOTENCY ==================== //
|
||||
|
||||
let idempotency_token = request.idempotency_token.as_deref()
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
if let Err(reason) = validate_idempotency_token_format(&idempotency_token) {
|
||||
return Err(CommonWebError::BadInputWithSimpleMessage(reason));
|
||||
}
|
||||
|
||||
insert_idempotency_token(&idempotency_token, &mut *mysql_connection)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
error!("Error inserting idempotency token: {:?}", err);
|
||||
CommonWebError::BadInputWithSimpleMessage("repeated idempotency token".to_string())
|
||||
})?;
|
||||
|
||||
// ==================== RESOLVE MEDIA TOKENS ==================== //
|
||||
|
||||
// Look up media file tokens BEFORE distilling. Pipeline execution should not do I/O.
|
||||
let resolved_media = lookup_media_files_as_cdn_url_list_and_map(
|
||||
&http_request,
|
||||
&mut mysql_connection,
|
||||
server_state.server_environment,
|
||||
request.image_media_tokens.as_deref().unwrap_or(&[]),
|
||||
).await?;
|
||||
|
||||
// ==================== HYDRATE ROUTER REQUEST ==================== //
|
||||
|
||||
let router_builder = hydrate_to_router_request(&request)?;
|
||||
|
||||
// ==================== DEBUG LOG: HTTP REQUEST ==================== //
|
||||
|
||||
if let Err(err) = insert_debug_log(InsertDebugLogArgs {
|
||||
apriori_debug_log_event_token: Some(&debug_log_event_token),
|
||||
maybe_creator_user_token: Some(user_token),
|
||||
debug_log_type: DebugLogType::HttpRequest,
|
||||
message: &serde_json::to_string(&*request).unwrap_or_default(),
|
||||
mysql_executor: &mut *mysql_connection,
|
||||
phantom: Default::default(),
|
||||
}).await {
|
||||
warn!("Failed to insert HTTP request debug log: {:?}", err);
|
||||
}
|
||||
|
||||
// ==================== PIPELINE ==================== //
|
||||
|
||||
// NB: Release the pooled DB connection before the (slow, external) generation call so we don't
|
||||
// hold a pool slot idle while waiting on the provider — that's what starves the pool and causes
|
||||
// PoolTimedOut on unrelated endpoints. We re-acquire below to write the result.
|
||||
drop(mysql_connection);
|
||||
|
||||
let pipeline_result = run_pipeline_v2(RunPipelineV2Args {
|
||||
router_builder: &router_builder,
|
||||
server_state: &server_state,
|
||||
user_token,
|
||||
resolved_media: &resolved_media,
|
||||
}).await?;
|
||||
|
||||
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
|
||||
|
||||
// ==================== DEBUG LOG: FAL REQUEST ==================== //
|
||||
|
||||
if let GenerateImageResponse::Fal(ref fal_payload) = pipeline_result.response {
|
||||
if let Some(ref outbound_request) = fal_payload.maybe_outbound_request {
|
||||
if let Err(err) = insert_debug_log(InsertDebugLogArgs {
|
||||
apriori_debug_log_event_token: Some(&debug_log_event_token),
|
||||
maybe_creator_user_token: Some(user_token),
|
||||
debug_log_type: DebugLogType::FalRequest,
|
||||
message: &format!("{:#?}", outbound_request),
|
||||
mysql_executor: &mut *mysql_connection,
|
||||
phantom: Default::default(),
|
||||
}).await {
|
||||
warn!("Failed to insert Fal request debug log: {:?}", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== WRITE RESULT ==================== //
|
||||
|
||||
let ip_address = get_request_ip(&http_request);
|
||||
// Omni API requests are always API-key authenticated; hardcode the platform type.
|
||||
let maybe_platform_type = Some(PlatformType::ApiKey);
|
||||
|
||||
let mut transaction = mysql_connection
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|err| {
|
||||
error!("Error starting MySQL transaction: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
// -- Prompt --
|
||||
|
||||
let generation_mode = if request.image_media_tokens.is_some() {
|
||||
CommonGenerationMode::Edit
|
||||
} else {
|
||||
CommonGenerationMode::Text
|
||||
};
|
||||
|
||||
let prompt_result = insert_prompt(InsertPromptArgs {
|
||||
maybe_apriori_prompt_token: None,
|
||||
prompt_type: PromptType::ArtcraftApp,
|
||||
maybe_creator_user_token: Some(user_token),
|
||||
maybe_model_type: maybe_prompt_model_type,
|
||||
maybe_generation_provider: Some(GenerationProvider::Artcraft),
|
||||
maybe_positive_prompt: request.prompt.as_deref(),
|
||||
maybe_negative_prompt: None,
|
||||
maybe_other_args: None,
|
||||
maybe_generation_mode: Some(generation_mode),
|
||||
maybe_aspect_ratio: request.aspect_ratio, // TODO: should be saved from router's decision as it could have changed
|
||||
maybe_resolution: request.resolution,// TODO: should be saved from router's decision as it could have changed
|
||||
maybe_batch_count: request.image_batch_count.map(|c| c as u8),
|
||||
maybe_generate_audio: None, // NB: Images, not video
|
||||
maybe_duration_seconds: None, // NB: Images, not video
|
||||
creator_ip_address: &ip_address,
|
||||
mysql_executor: &mut *transaction,
|
||||
phantom: Default::default(),
|
||||
}).await;
|
||||
|
||||
let prompt_token = match prompt_result {
|
||||
Ok(token) => Some(token),
|
||||
Err(err) => {
|
||||
warn!("Error inserting prompt: {:?}", err);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
// -- Prompt context items --
|
||||
|
||||
if let Some(token) = prompt_token.as_ref() {
|
||||
let mut context_items = Vec::new();
|
||||
|
||||
if let Some(ref_tokens) = &request.image_media_tokens {
|
||||
for media_token in ref_tokens {
|
||||
context_items.push(PromptContextItem {
|
||||
media_token: media_token.clone(),
|
||||
context_semantic_type: PromptContextSemanticType::Imgref,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if !context_items.is_empty() {
|
||||
if let Err(err) = insert_batch_prompt_context_items(InsertBatchArgs {
|
||||
prompt_token: token.clone(),
|
||||
items: context_items,
|
||||
transaction: &mut transaction,
|
||||
}).await {
|
||||
warn!("Error inserting batch prompt context items: {:?}", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- Inference job --
|
||||
//
|
||||
// Each provider has its own queue / worker. Branch by response variant
|
||||
// so the row lands on the correct queue (the Seedance2Pro/Kinovi worker
|
||||
// for Midjourney; the Fal worker for everything else).
|
||||
|
||||
let job_token: InferenceJobToken = match &pipeline_result.response {
|
||||
GenerateImageResponse::Seedance2Pro(payload) => {
|
||||
info!("Inserting seedance2pro image job(s) with token: {:?}", pipeline_result.apriori_job_token);
|
||||
|
||||
// The image-side omni pipeline always dispatches Midjourney via the
|
||||
// Volcengine Kinovi account today. If we ever route to BytePlus /
|
||||
// BytePlus Ultra here, mirror the video-side `kinovi_account` knob.
|
||||
let kinovi_version = KinoviVersion::Volcengine;
|
||||
|
||||
let result = insert_seedance2pro_jobs(InsertSeedance2proJobsArgs {
|
||||
primary_order_id: &payload.order_id,
|
||||
maybe_additional_order_ids: payload.maybe_order_ids.as_deref(),
|
||||
maybe_wallet_ledger_entry_token: pipeline_result.maybe_wallet_ledger_entry_token.as_ref(),
|
||||
kinovi_version,
|
||||
shared: SharedJobArgs {
|
||||
apriori_job_token: &pipeline_result.apriori_job_token,
|
||||
idempotency_token: &idempotency_token,
|
||||
user_token,
|
||||
maybe_avt_token: None, // AVT tokens are web-session only; API-key callers have none.
|
||||
maybe_model_type: request.model.map(|v| v.to_common_model_type()),
|
||||
maybe_prompt_token: prompt_token.as_ref(),
|
||||
maybe_debug_log_event_token: Some(&debug_log_event_token),
|
||||
maybe_platform_type,
|
||||
ip_address: &ip_address,
|
||||
transaction: &mut transaction,
|
||||
},
|
||||
}).await?;
|
||||
result.primary_job_token
|
||||
}
|
||||
GenerateImageResponse::Fal(payload) => {
|
||||
info!("Inserting fal image job with token: {:?}", pipeline_result.apriori_job_token);
|
||||
let external_job_id = payload.request_id.clone().unwrap_or_default();
|
||||
insert_fal_job(InsertFalJobArgs {
|
||||
external_job_id: &external_job_id,
|
||||
shared: SharedJobArgs {
|
||||
apriori_job_token: &pipeline_result.apriori_job_token,
|
||||
idempotency_token: &idempotency_token,
|
||||
user_token,
|
||||
maybe_avt_token: None, // AVT tokens are web-session only; API-key callers have none.
|
||||
maybe_model_type: request.model.map(|v| v.to_common_model_type()),
|
||||
maybe_prompt_token: prompt_token.as_ref(),
|
||||
maybe_debug_log_event_token: Some(&debug_log_event_token),
|
||||
maybe_platform_type,
|
||||
ip_address: &ip_address,
|
||||
transaction: &mut transaction,
|
||||
},
|
||||
}).await?
|
||||
}
|
||||
GenerateImageResponse::Artcraft(payload) => {
|
||||
// The omni image pipeline never dispatches via the Artcraft provider
|
||||
// itself today (everything routes to Fal or Kinovi), but the response
|
||||
// variant exists so we cover it defensively — Artcraft jobs come back
|
||||
// already inserted server-side, so just propagate the token.
|
||||
payload.inference_job_token.clone()
|
||||
}
|
||||
};
|
||||
|
||||
transaction.commit().await.map_err(|err| {
|
||||
error!("Error committing transaction: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
Ok(Json(OmniGenImageGenerateResponse {
|
||||
success: true,
|
||||
inference_job_token: job_token,
|
||||
}))
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
use artcraft_router::generate::generate_image::generate_image_response::GenerateImageResponse;
|
||||
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
|
||||
use tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken;
|
||||
|
||||
/// Both image pipelines produce this, then the shared handler suffix writes DB rows.
|
||||
pub struct ImagePipelineResult {
|
||||
pub apriori_job_token: InferenceJobToken,
|
||||
pub response: GenerateImageResponse,
|
||||
|
||||
/// The wallet ledger entry created when the user was charged for this
|
||||
/// generation, if any (None when the generation is free). Used as a
|
||||
/// foreign key on the inference job row so the worker can refund on
|
||||
/// failure.
|
||||
pub maybe_wallet_ledger_entry_token: Option<WalletLedgerEntryToken>,
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
pub mod run_pipeline_v2;
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
use log::{info, warn};
|
||||
|
||||
use artcraft_router::api::image_list_ref::ImageListRef;
|
||||
use artcraft_router::api::router_image_model::RouterImageModel;
|
||||
use artcraft_router::api::router_provider::RouterProvider;
|
||||
use artcraft_router::client::router_client::RouterClient;
|
||||
use artcraft_router::client::router_fal_client::RouterFalClient;
|
||||
use artcraft_router::client::router_seedance2pro_client::RouterSeedance2ProClient;
|
||||
use artcraft_router::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
|
||||
use artcraft_router::generate::generate_image::generate_image_response::GenerateImageResponse;
|
||||
use artcraft_router::generate::generate_image::image_generation_draft_context::ImageGenerationDraftContext;
|
||||
use artcraft_router::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest;
|
||||
use artcraft_router::generate::generate_image::image_generation_request::ImageGenerationRequest;
|
||||
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
|
||||
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
use crate::billing::wallets::attempt_wallet_deduction::attempt_wallet_deduction_else_common_web_error;
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::endpoints::omni_api::generate::image::pipeline_result::ImagePipelineResult;
|
||||
use crate::http_server::endpoints::omni_api::shared_utils::map_seedance2pro_router_error::map_router_error_to_web_error;
|
||||
use crate::state::server_state::ServerState;
|
||||
use crate::util::lookup::lookup_media_files_as_cdn_url_list_and_map::MediaFilesAsCdnUrlListAndMap;
|
||||
|
||||
pub struct RunPipelineV2Args<'a> {
|
||||
pub router_builder: &'a GenerateImageRequestBuilder,
|
||||
pub server_state: &'a ServerState,
|
||||
pub user_token: &'a UserToken,
|
||||
pub resolved_media: &'a MediaFilesAsCdnUrlListAndMap,
|
||||
}
|
||||
|
||||
// NB: This pipeline does an external generation call (`finalize_and_generate`) that can take many
|
||||
// seconds. It deliberately does NOT hold a pooled DB connection across that call — it acquires a
|
||||
// short-lived connection only for the wallet deduction. Holding a pooled connection across the
|
||||
// external call is what starves the pool and causes `PoolTimedOut`.
|
||||
pub async fn run_pipeline_v2(
|
||||
args: RunPipelineV2Args<'_>,
|
||||
) -> Result<ImagePipelineResult, CommonWebError> {
|
||||
let RunPipelineV2Args {
|
||||
router_builder,
|
||||
server_state,
|
||||
user_token,
|
||||
resolved_media,
|
||||
} = args;
|
||||
|
||||
let hydrated_builder = apply_hydrated_media_inputs(
|
||||
router_builder,
|
||||
resolved_media,
|
||||
);
|
||||
|
||||
let cost = estimate_cost_in_credits(&router_builder)?;
|
||||
|
||||
let draft_or_request = build_execution_request(&hydrated_builder)?;
|
||||
|
||||
info!("Charging wallet: {} credits", cost);
|
||||
|
||||
let apriori_job_token = InferenceJobToken::generate();
|
||||
|
||||
let maybe_wallet_ledger_entry_token = if cost > 0 {
|
||||
// NB: Short-lived connection — released before the external call below.
|
||||
let mut billing_connection = server_state.mysql_pool.acquire().await
|
||||
.map_err(|err| {
|
||||
warn!("Failed to acquire MySQL connection for image billing: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
let deduction = attempt_wallet_deduction_else_common_web_error(
|
||||
user_token,
|
||||
Some(apriori_job_token.as_str()),
|
||||
cost,
|
||||
&mut billing_connection,
|
||||
).await?;
|
||||
Some(deduction.ledger_entry_token)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// NB: No pooled DB connection is held across this external generation call.
|
||||
let response = finalize_and_generate(draft_or_request, server_state, resolved_media).await?;
|
||||
|
||||
Ok(ImagePipelineResult {
|
||||
apriori_job_token,
|
||||
response,
|
||||
maybe_wallet_ledger_entry_token,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_execution_request(
|
||||
router_builder: &GenerateImageRequestBuilder,
|
||||
) -> Result<ImageGenerationDraftOrRequest, CommonWebError> {
|
||||
let mut execution_builder = router_builder.clone();
|
||||
execution_builder.provider = provider_for_model(execution_builder.model);
|
||||
|
||||
execution_builder.build2().map_err(|e| {
|
||||
warn!("Failed to build2 for image v2 pipeline: {}", e);
|
||||
CommonWebError::from_error(e)
|
||||
})
|
||||
}
|
||||
|
||||
/// Route each image model to the provider that fulfils it. Midjourney is
|
||||
/// served via the Seedance2Pro/Kinovi (Volcengine) backend; everything else
|
||||
/// flows through Fal.
|
||||
fn provider_for_model(model: RouterImageModel) -> RouterProvider {
|
||||
match model {
|
||||
RouterImageModel::Midjourney7
|
||||
| RouterImageModel::Midjourney7Niji
|
||||
| RouterImageModel::Midjourney8 => RouterProvider::Seedance2Pro,
|
||||
_ => RouterProvider::Fal,
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_hydrated_media_inputs(
|
||||
router_builder: &GenerateImageRequestBuilder,
|
||||
resolved_media: &MediaFilesAsCdnUrlListAndMap,
|
||||
) -> GenerateImageRequestBuilder {
|
||||
let mut hydrated_builder = router_builder.clone();
|
||||
|
||||
match hydrated_builder.image_inputs.as_ref() {
|
||||
Some(ImageListRef::MediaFileTokens(tokens)) if !tokens.is_empty() => {
|
||||
hydrated_builder.image_inputs = Some(ImageListRef::Urls(
|
||||
resolved_media.ordered_url_list.clone(),
|
||||
));
|
||||
},
|
||||
_ => {},
|
||||
}
|
||||
|
||||
hydrated_builder
|
||||
}
|
||||
|
||||
fn estimate_cost_in_credits(
|
||||
router_builder: &GenerateImageRequestBuilder,
|
||||
) -> Result<u64, CommonWebError> {
|
||||
let mut cost_builder = router_builder.clone();
|
||||
cost_builder.provider = RouterProvider::Artcraft;
|
||||
|
||||
let request = cost_builder.build2().map_err(|e| {
|
||||
warn!("Failed to build image cost request: {}", e);
|
||||
CommonWebError::from_error(e)
|
||||
})?;
|
||||
|
||||
let cost = request.estimate_cost().map_err(|e| {
|
||||
warn!("Failed to estimate image cost: {}", e);
|
||||
CommonWebError::from_error(e)
|
||||
})?;
|
||||
|
||||
Ok(cost.cost_in_credits.unwrap_or(0))
|
||||
}
|
||||
|
||||
async fn finalize_and_generate(
|
||||
draft_or_request: ImageGenerationDraftOrRequest,
|
||||
server_state: &ServerState,
|
||||
resolved_media: &MediaFilesAsCdnUrlListAndMap,
|
||||
) -> Result<GenerateImageResponse, CommonWebError> {
|
||||
let provider = draft_or_request.get_provider();
|
||||
let client = build_router_client(provider, server_state)?;
|
||||
|
||||
// Inline the draft finalization so the draft context can borrow `client`.
|
||||
let request: ImageGenerationRequest = match draft_or_request {
|
||||
ImageGenerationDraftOrRequest::Request(request) => request,
|
||||
ImageGenerationDraftOrRequest::Draft(draft) => {
|
||||
let draft_context = ImageGenerationDraftContext {
|
||||
client: Some(&client),
|
||||
media_file_to_artcraft_url_map: Some(&resolved_media.token_to_url_map),
|
||||
};
|
||||
draft.finalize(draft_context)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!("Failed to finalize image v2 draft: {:?}", err);
|
||||
map_router_error_to_web_error(err)
|
||||
})?
|
||||
}
|
||||
};
|
||||
|
||||
request.send_request(&client)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!("v2 image generation failed: {:?}", err);
|
||||
map_router_error_to_web_error(err)
|
||||
})
|
||||
}
|
||||
|
||||
fn build_router_client(
|
||||
provider: RouterProvider,
|
||||
server_state: &ServerState,
|
||||
) -> Result<RouterClient, CommonWebError> {
|
||||
match provider {
|
||||
RouterProvider::Fal => {
|
||||
let fal_client = RouterFalClient::new_with_webhook(
|
||||
server_state.inference_providers.fal.api_key.clone(),
|
||||
server_state.inference_providers.fal.webhook_url.clone(),
|
||||
);
|
||||
Ok(RouterClient::Fal(fal_client))
|
||||
},
|
||||
RouterProvider::Seedance2Pro => {
|
||||
// Midjourney image generation always uses the Volcengine account.
|
||||
// Per-request account selection (like the video flow's
|
||||
// `KinoviAccount` enum) isn't needed here yet — if it ever is, plumb
|
||||
// a parallel knob through `RunPipelineV2Args`.
|
||||
let cookies = server_state.inference_providers.seedance2pro.cookies_volcengine.clone();
|
||||
let session = Seedance2ProSession::from_cookies_string(cookies);
|
||||
Ok(RouterClient::Seedance2Pro(RouterSeedance2ProClient::new(session)))
|
||||
},
|
||||
other => {
|
||||
Err(CommonWebError::server_error_with_message(
|
||||
&format!("Unsupported provider for image v2 generation: {:?}", other),
|
||||
))
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod image;
|
||||
pub mod video;
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
//! Shared wallet billing logic for both video generation pipelines.
|
||||
|
||||
use log::info;
|
||||
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
use tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken;
|
||||
|
||||
use crate::billing::wallets::attempt_wallet_deduction::attempt_wallet_deduction_else_common_web_error;
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
|
||||
pub struct BillWalletResult {
|
||||
pub apriori_job_token: InferenceJobToken,
|
||||
pub maybe_wallet_ledger_entry_token: Option<WalletLedgerEntryToken>,
|
||||
}
|
||||
|
||||
/// Generate an apriori job token and bill the user's wallet for the given cost.
|
||||
///
|
||||
/// If cost is 0, no wallet deduction is made but the apriori token is still generated.
|
||||
pub async fn bill_wallet(
|
||||
user_token: &UserToken,
|
||||
cost: u64,
|
||||
mysql_connection: &mut sqlx::pool::PoolConnection<sqlx::MySql>,
|
||||
) -> Result<BillWalletResult, CommonWebError> {
|
||||
let apriori_job_token = InferenceJobToken::generate();
|
||||
|
||||
info!("Charging wallet: {} credits", cost);
|
||||
|
||||
let maybe_wallet_ledger_entry_token = if cost > 0 {
|
||||
let deduction_result = attempt_wallet_deduction_else_common_web_error(
|
||||
user_token,
|
||||
Some(apriori_job_token.as_str()),
|
||||
cost,
|
||||
mysql_connection,
|
||||
).await?;
|
||||
Some(deduction_result.ledger_entry_token)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(BillWalletResult {
|
||||
apriori_job_token,
|
||||
maybe_wallet_ledger_entry_token,
|
||||
})
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
//! Build a RouterClient for the given provider from server state.
|
||||
|
||||
use artcraft_router::api::router_provider::RouterProvider;
|
||||
use artcraft_router::client::router_client::RouterClient;
|
||||
use artcraft_router::client::router_fal_client::RouterFalClient;
|
||||
use artcraft_router::client::router_gmicloud_client::RouterGmiCloudClient;
|
||||
use artcraft_router::client::router_grok_api_client::RouterGrokApiClient;
|
||||
use artcraft_router::client::router_seedance2pro_client::RouterSeedance2ProClient;
|
||||
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::endpoints::omni_api::generate::video::kinovi_account::KinoviAccount;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
pub fn build_router_client(
|
||||
provider: RouterProvider,
|
||||
server_state: &ServerState,
|
||||
kinovi_account: KinoviAccount,
|
||||
) -> Result<RouterClient, CommonWebError> {
|
||||
match provider {
|
||||
RouterProvider::Seedance2Pro => {
|
||||
kinovi_provider(server_state, kinovi_account)
|
||||
}
|
||||
RouterProvider::Fal => {
|
||||
let fal_client = RouterFalClient::new_with_webhook(
|
||||
server_state.inference_providers.fal.api_key.clone(),
|
||||
server_state.inference_providers.fal.webhook_url.clone(),
|
||||
);
|
||||
Ok(RouterClient::Fal(fal_client))
|
||||
}
|
||||
RouterProvider::GmiCloud => {
|
||||
Ok(RouterClient::GmiCloud(RouterGmiCloudClient::new(
|
||||
server_state.inference_providers.gmicloud.api_key.clone(),
|
||||
)))
|
||||
}
|
||||
RouterProvider::GrokApi => {
|
||||
Ok(RouterClient::GrokApi(RouterGrokApiClient::new(
|
||||
server_state.inference_providers.grok_api.api_key.clone(),
|
||||
)))
|
||||
}
|
||||
other => {
|
||||
Err(CommonWebError::server_error_with_message(
|
||||
&format!("Unsupported provider for video generation: {:?}", other),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn kinovi_provider(server_state: &ServerState, kinovi_account: KinoviAccount) -> Result<RouterClient, CommonWebError> {
|
||||
let seedance2pro = &server_state.inference_providers.seedance2pro;
|
||||
|
||||
let cookies = match kinovi_account {
|
||||
KinoviAccount::Volcengine => seedance2pro.cookies_volcengine.clone(),
|
||||
KinoviAccount::BytePlus => seedance2pro.cookies_byteplus.clone(),
|
||||
KinoviAccount::BytePlusUltra => seedance2pro.cookies_byteplus_ultra.clone(),
|
||||
};
|
||||
|
||||
let session = Seedance2ProSession::from_cookies_string(cookies);
|
||||
|
||||
Ok(RouterClient::Seedance2Pro(RouterSeedance2ProClient::new(session)))
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
|
||||
use artcraft_router::api::audio_list_ref::AudioListRef;
|
||||
use artcraft_router::api::character_list_ref::CharacterListRef;
|
||||
use artcraft_router::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use artcraft_router::api::router_resolution::RouterResolution;
|
||||
use artcraft_router::api::router_video_model::RouterVideoModel;
|
||||
use artcraft_router::api::image_list_ref::ImageListRef;
|
||||
use artcraft_router::api::image_ref::ImageRef;
|
||||
use artcraft_router::api::router_provider::RouterProvider;
|
||||
use artcraft_router::api::video_list_ref::VideoListRef;
|
||||
use artcraft_router::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
use artcraft_router::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use enums::common::generation::common_aspect_ratio::CommonAspectRatio as CommonAspectRatioEnum;
|
||||
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
|
||||
|
||||
pub fn hydrate_to_router_request(
|
||||
request: &OmniGenVideoCostAndGenerateRequest,
|
||||
) -> Result<GenerateVideoRequestBuilder, CommonWebError> {
|
||||
let api_model = request.model
|
||||
.as_ref()
|
||||
.ok_or_else(|| CommonWebError::BadInputWithSimpleMessage(
|
||||
"model is required".to_string(),
|
||||
))?;
|
||||
|
||||
let model = convert_model(api_model)?;
|
||||
|
||||
let aspect_ratio = request.aspect_ratio
|
||||
.as_ref()
|
||||
.map(convert_aspect_ratio)
|
||||
.transpose()?;
|
||||
|
||||
let resolution = request.resolution
|
||||
.as_ref()
|
||||
.map(convert_resolution)
|
||||
.transpose()?;
|
||||
|
||||
Ok(GenerateVideoRequestBuilder {
|
||||
model,
|
||||
provider: RouterProvider::Artcraft,
|
||||
prompt: request.prompt.clone(),
|
||||
negative_prompt: request.negative_prompt.clone(),
|
||||
start_frame: request.start_frame_image_media_token.clone()
|
||||
.map(ImageRef::MediaFileToken),
|
||||
end_frame: request.end_frame_image_media_token.clone()
|
||||
.map(ImageRef::MediaFileToken),
|
||||
reference_images: request.reference_image_media_tokens.clone()
|
||||
.map(ImageListRef::MediaFileTokens),
|
||||
reference_videos: request.reference_video_media_tokens.clone()
|
||||
.map(VideoListRef::MediaFileTokens),
|
||||
reference_audio: request.reference_audio_media_tokens.clone()
|
||||
.map(AudioListRef::MediaFileTokens),
|
||||
reference_character_tokens: request.reference_character_tokens.clone()
|
||||
.map(CharacterListRef::CharacterTokens),
|
||||
resolution,
|
||||
aspect_ratio,
|
||||
duration_seconds: request.duration_seconds,
|
||||
video_batch_count: request.video_batch_count,
|
||||
generate_audio: request.generate_audio,
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
|
||||
idempotency_token: request.idempotency_token.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn convert_model(
|
||||
model: &CommonVideoModelEnum,
|
||||
) -> Result<RouterVideoModel, CommonWebError> {
|
||||
let json = serde_json::to_string(model)?;
|
||||
serde_json::from_str(&json).map_err(|e| {
|
||||
CommonWebError::BadInputWithSimpleMessage(
|
||||
format!("Unsupported video model: {}", e),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn convert_aspect_ratio(
|
||||
ar: &CommonAspectRatioEnum,
|
||||
) -> Result<RouterAspectRatio, CommonWebError> {
|
||||
let json = serde_json::to_string(ar)?;
|
||||
serde_json::from_str(&json).map_err(|e| {
|
||||
CommonWebError::BadInputWithSimpleMessage(
|
||||
format!("Unsupported aspect ratio: {}", e),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn convert_resolution(
|
||||
res: &CommonResolutionEnum,
|
||||
) -> Result<RouterResolution, CommonWebError> {
|
||||
let json = serde_json::to_string(res)?;
|
||||
serde_json::from_str(&json).map_err(|e| {
|
||||
CommonWebError::BadInputWithSimpleMessage(
|
||||
format!("Unsupported resolution: {}", e),
|
||||
)
|
||||
})
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
pub mod bill_wallet;
|
||||
pub mod build_router_client;
|
||||
pub(crate) mod hydrate_router_request;
|
||||
pub mod pipeline_result;
|
||||
pub mod resolve_kinovi_character_ids;
|
||||
pub mod resolve_media_tokens_to_urls;
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
use artcraft_router::generate::generate_video::generate_video_response::GenerateVideoResponse;
|
||||
use mysql_queries::queries::generic_inference::common::job_cost_estimates::JobCostEstimates;
|
||||
|
||||
use crate::http_server::endpoints::omni_api::generate::video::helpers::bill_wallet::BillWalletResult;
|
||||
|
||||
/// Both pipelines produce this, then the shared suffix handles DB writes.
|
||||
pub struct PipelineResult {
|
||||
pub billing: BillWalletResult,
|
||||
pub response: GenerateVideoResponse,
|
||||
|
||||
/// System (user-facing) and provider-side cost estimates, written onto the
|
||||
/// generic_inference_jobs rows.
|
||||
pub cost_estimates: JobCostEstimates,
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
//! Resolve character tokens to Kinovi character IDs for prompting.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use log::warn;
|
||||
use sqlx::MySql;
|
||||
|
||||
use mysql_queries::queries::characters::batch_lookup_characters_by_token_for_prompting::batch_lookup_characters_by_token_for_prompting;
|
||||
use tokens::tokens::characters::CharacterToken;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
|
||||
/// Resolve character tokens to a map of CharacterToken → Kinovi character ID.
|
||||
///
|
||||
/// Looks up the characters, filters to active ones with kinovi IDs, and warns about
|
||||
/// any that are missing or inactive (but doesn't fail the request).
|
||||
pub async fn resolve_kinovi_character_ids(
|
||||
maybe_tokens: Option<&[CharacterToken]>,
|
||||
connection: &mut sqlx::pool::PoolConnection<MySql>,
|
||||
) -> Result<Option<HashMap<CharacterToken, String>>, CommonWebError> {
|
||||
let tokens = match maybe_tokens {
|
||||
None => return Ok(None),
|
||||
Some(tokens) if tokens.is_empty() => return Ok(None),
|
||||
Some(tokens) => tokens,
|
||||
};
|
||||
|
||||
let characters = batch_lookup_characters_by_token_for_prompting(tokens, connection)
|
||||
.await?;
|
||||
|
||||
if characters.len() != tokens.len() {
|
||||
warn!(
|
||||
"Not all character tokens were found: requested {}, found {}",
|
||||
tokens.len(), characters.len(),
|
||||
);
|
||||
}
|
||||
|
||||
for character in &characters {
|
||||
if !character.is_active {
|
||||
warn!("Character {} is not yet active, skipping", character.token);
|
||||
}
|
||||
}
|
||||
|
||||
let map: HashMap<CharacterToken, String> = characters.iter()
|
||||
.filter(|c| c.is_active)
|
||||
.filter_map(|c| {
|
||||
c.kinovi_character_id.as_ref().map(|kid| (c.token.clone(), kid.clone()))
|
||||
})
|
||||
.collect();
|
||||
|
||||
if map.is_empty() { Ok(None) } else { Ok(Some(map)) }
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use artcraft_router::api::audio_list_ref::AudioListRef;
|
||||
use artcraft_router::api::image_list_ref::ImageListRef;
|
||||
use artcraft_router::api::image_ref::ImageRef;
|
||||
use artcraft_router::api::video_list_ref::VideoListRef;
|
||||
use artcraft_router::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
/// For providers that take URLs directly (GmiCloud, Fal), swap
|
||||
/// `ImageRef::MediaFileToken` → `ImageRef::Url` using the resolved map.
|
||||
pub fn resolve_media_tokens_to_urls(
|
||||
builder: &mut GenerateVideoRequestBuilder,
|
||||
url_map: Option<&HashMap<MediaFileToken, String>>,
|
||||
) {
|
||||
let map = match url_map {
|
||||
Some(m) => m,
|
||||
None => return,
|
||||
};
|
||||
|
||||
// start_frame
|
||||
if let Some(ImageRef::MediaFileToken(ref token)) = builder.start_frame {
|
||||
if let Some(url) = map.get(token) {
|
||||
builder.start_frame = Some(ImageRef::Url(url.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
// end_frame
|
||||
if let Some(ImageRef::MediaFileToken(ref token)) = builder.end_frame {
|
||||
if let Some(url) = map.get(token) {
|
||||
builder.end_frame = Some(ImageRef::Url(url.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
// reference_images
|
||||
if let Some(ImageListRef::MediaFileTokens(ref tokens)) = builder.reference_images {
|
||||
let urls: Vec<String> = tokens.iter()
|
||||
.filter_map(|t| map.get(t).cloned())
|
||||
.collect();
|
||||
if !urls.is_empty() {
|
||||
builder.reference_images = Some(ImageListRef::Urls(urls));
|
||||
}
|
||||
}
|
||||
|
||||
// reference_videos
|
||||
if let Some(VideoListRef::MediaFileTokens(ref tokens)) = builder.reference_videos {
|
||||
let urls: Vec<String> = tokens.iter()
|
||||
.filter_map(|t| map.get(t).cloned())
|
||||
.collect();
|
||||
if !urls.is_empty() {
|
||||
builder.reference_videos = Some(VideoListRef::Urls(urls));
|
||||
}
|
||||
}
|
||||
|
||||
// reference_audio
|
||||
if let Some(AudioListRef::MediaFileTokens(ref tokens)) = builder.reference_audio {
|
||||
let urls: Vec<String> = tokens.iter()
|
||||
.filter_map(|t| map.get(t).cloned())
|
||||
.collect();
|
||||
if !urls.is_empty() {
|
||||
builder.reference_audio = Some(AudioListRef::Urls(urls));
|
||||
}
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
use log::warn;
|
||||
|
||||
use enums::common::visibility::Visibility;
|
||||
use mysql_queries::queries::generic_inference::api_providers::fal::insert_generic_inference_job_for_fal_queue::FalCategory;
|
||||
use mysql_queries::queries::generic_inference::api_providers::fal::insert_generic_inference_job_for_fal_queue_with_apriori_job_token::{
|
||||
insert_generic_inference_job_for_fal_queue_with_apriori_job_token,
|
||||
InsertGenericInferenceForFalWithAprioriJobTokenArgs,
|
||||
};
|
||||
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
|
||||
use super::shared_job_args::SharedJobArgs;
|
||||
|
||||
pub struct InsertFalJobArgs<'a, 'tx> {
|
||||
pub external_job_id: &'a str,
|
||||
pub shared: SharedJobArgs<'a, 'tx>,
|
||||
}
|
||||
|
||||
pub async fn insert_fal_job(args: InsertFalJobArgs<'_, '_>) -> Result<InferenceJobToken, CommonWebError> {
|
||||
let InsertFalJobArgs {
|
||||
external_job_id,
|
||||
shared,
|
||||
} = args;
|
||||
|
||||
let db_result = insert_generic_inference_job_for_fal_queue_with_apriori_job_token(
|
||||
InsertGenericInferenceForFalWithAprioriJobTokenArgs {
|
||||
apriori_job_token: shared.apriori_job_token,
|
||||
uuid_idempotency_token: shared.idempotency_token,
|
||||
maybe_external_third_party_id: external_job_id,
|
||||
fal_category: FalCategory::VideoGeneration,
|
||||
maybe_model_type: shared.maybe_model_type,
|
||||
maybe_prompt_token: shared.maybe_prompt_token,
|
||||
maybe_creator_user_token: Some(shared.user_token),
|
||||
maybe_avt_token: shared.maybe_avt_token,
|
||||
creator_ip_address: shared.ip_address,
|
||||
creator_set_visibility: Visibility::Public,
|
||||
maybe_platform_type: shared.maybe_platform_type,
|
||||
maybe_cost_estimates: shared.maybe_cost_estimates,
|
||||
maybe_debug_log_event_token: shared.maybe_debug_log_event_token,
|
||||
mysql_executor: &mut **shared.transaction,
|
||||
maybe_inference_args: None,
|
||||
starting_job_status_override: None,
|
||||
maybe_frontend_failure_category: None,
|
||||
maybe_failure_reason: None,
|
||||
phantom: Default::default(),
|
||||
}
|
||||
).await;
|
||||
|
||||
match db_result {
|
||||
Ok(token) => Ok(token),
|
||||
Err(err) => {
|
||||
warn!("Error inserting fal inference job: {:?}", err);
|
||||
Err(CommonWebError::from_error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
use log::warn;
|
||||
|
||||
use mysql_queries::queries::generic_inference::api_providers::gmicloud::insert_generic_inference_job_for_gmicloud::{
|
||||
insert_generic_inference_job_for_gmicloud, InsertGmiCloudInferenceJobArgs,
|
||||
};
|
||||
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
|
||||
use super::shared_job_args::SharedJobArgs;
|
||||
|
||||
pub struct InsertGmiCloudJobArgs<'a, 'tx> {
|
||||
pub external_request_id: &'a str,
|
||||
pub shared: SharedJobArgs<'a, 'tx>,
|
||||
}
|
||||
|
||||
pub async fn insert_gmicloud_job(args: InsertGmiCloudJobArgs<'_, '_>) -> Result<InferenceJobToken, CommonWebError> {
|
||||
let InsertGmiCloudJobArgs {
|
||||
external_request_id,
|
||||
shared,
|
||||
} = args;
|
||||
|
||||
let db_result = insert_generic_inference_job_for_gmicloud(
|
||||
InsertGmiCloudInferenceJobArgs {
|
||||
apriori_job_token: shared.apriori_job_token,
|
||||
uuid_idempotency_token: shared.idempotency_token,
|
||||
external_request_id,
|
||||
maybe_model_type: shared.maybe_model_type,
|
||||
maybe_prompt_token: shared.maybe_prompt_token,
|
||||
maybe_creator_user_token: Some(shared.user_token),
|
||||
maybe_avt_token: shared.maybe_avt_token,
|
||||
creator_ip_address: shared.ip_address,
|
||||
maybe_platform_type: shared.maybe_platform_type,
|
||||
maybe_cost_estimates: shared.maybe_cost_estimates,
|
||||
maybe_debug_log_event_token: shared.maybe_debug_log_event_token,
|
||||
mysql_executor: &mut **shared.transaction,
|
||||
phantom: Default::default(),
|
||||
}
|
||||
).await;
|
||||
|
||||
match db_result {
|
||||
Ok(token) => Ok(token),
|
||||
Err(err) => {
|
||||
warn!("Error inserting GmiCloud inference job: {:?}", err);
|
||||
Err(CommonWebError::from_error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
use log::warn;
|
||||
|
||||
use mysql_queries::queries::generic_inference::api_providers::grok_api::insert_generic_inference_job_for_grok_api::{
|
||||
insert_generic_inference_job_for_grok_api, InsertGrokApiInferenceJobArgs,
|
||||
};
|
||||
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
|
||||
use super::shared_job_args::SharedJobArgs;
|
||||
|
||||
pub struct InsertGrokApiJobArgs<'a, 'tx> {
|
||||
pub external_request_id: &'a str,
|
||||
pub shared: SharedJobArgs<'a, 'tx>,
|
||||
}
|
||||
|
||||
pub async fn insert_grok_api_job(args: InsertGrokApiJobArgs<'_, '_>) -> Result<InferenceJobToken, CommonWebError> {
|
||||
let InsertGrokApiJobArgs {
|
||||
external_request_id,
|
||||
shared,
|
||||
} = args;
|
||||
|
||||
let db_result = insert_generic_inference_job_for_grok_api(
|
||||
InsertGrokApiInferenceJobArgs {
|
||||
apriori_job_token: shared.apriori_job_token,
|
||||
uuid_idempotency_token: shared.idempotency_token,
|
||||
external_request_id,
|
||||
maybe_model_type: shared.maybe_model_type,
|
||||
maybe_prompt_token: shared.maybe_prompt_token,
|
||||
maybe_creator_user_token: Some(shared.user_token),
|
||||
maybe_avt_token: shared.maybe_avt_token,
|
||||
creator_ip_address: shared.ip_address,
|
||||
maybe_platform_type: shared.maybe_platform_type,
|
||||
maybe_cost_estimates: shared.maybe_cost_estimates,
|
||||
maybe_debug_log_event_token: shared.maybe_debug_log_event_token,
|
||||
mysql_executor: &mut **shared.transaction,
|
||||
phantom: Default::default(),
|
||||
}
|
||||
).await;
|
||||
|
||||
match db_result {
|
||||
Ok(token) => Ok(token),
|
||||
Err(err) => {
|
||||
warn!("Error inserting Grok (xAI) API inference job: {:?}", err);
|
||||
Err(CommonWebError::from_error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
use log::{error, warn};
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use enums::common::visibility::Visibility;
|
||||
use mysql_queries::queries::generic_inference::api_providers::seedance2pro::insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token::{insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token, InsertGenericInferenceForSeedance2ProWithAprioriJobTokenArgs, KinoviVersion};
|
||||
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
|
||||
use tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken;
|
||||
use uuid_utils::uuid::generate_random_uuid;
|
||||
|
||||
use super::shared_job_args::SharedJobArgs;
|
||||
|
||||
pub struct InsertSeedance2proJobsArgs<'a, 'tx> {
|
||||
pub primary_order_id: &'a str,
|
||||
pub maybe_additional_order_ids: Option<&'a [String]>,
|
||||
pub maybe_wallet_ledger_entry_token: Option<&'a WalletLedgerEntryToken>,
|
||||
pub kinovi_version: KinoviVersion,
|
||||
pub shared: SharedJobArgs<'a, 'tx>,
|
||||
}
|
||||
|
||||
pub struct InsertSeedance2proJobsResult {
|
||||
pub primary_job_token: InferenceJobToken,
|
||||
pub all_job_tokens: Vec<InferenceJobToken>,
|
||||
}
|
||||
|
||||
pub async fn insert_seedance2pro_jobs(args: InsertSeedance2proJobsArgs<'_, '_>) -> Result<InsertSeedance2proJobsResult, CommonWebError> {
|
||||
let InsertSeedance2proJobsArgs {
|
||||
primary_order_id,
|
||||
maybe_additional_order_ids,
|
||||
maybe_wallet_ledger_entry_token,
|
||||
kinovi_version,
|
||||
mut shared,
|
||||
} = args;
|
||||
|
||||
// Build a deduplicated list with primary_order_id first.
|
||||
let mut all_order_ids = vec![primary_order_id.to_string()];
|
||||
|
||||
if let Some(additional) = maybe_additional_order_ids {
|
||||
for id in additional {
|
||||
if id != primary_order_id {
|
||||
all_order_ids.push(id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut all_job_tokens: Vec<InferenceJobToken> = Vec::with_capacity(all_order_ids.len());
|
||||
|
||||
for (i, order_id) in all_order_ids.iter().enumerate() {
|
||||
let job_token = if i == 0 { shared.apriori_job_token.clone() } else { InferenceJobToken::generate() };
|
||||
|
||||
// TODO: These strings are too long for the database:
|
||||
//let idempotency_str = if i == 0 { shared.idempotency_token.to_string() } else { format!("{}-batch-{}", shared.idempotency_token, i) };
|
||||
|
||||
let idempotency_str = generate_random_uuid();
|
||||
|
||||
let db_result = insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token(
|
||||
InsertGenericInferenceForSeedance2ProWithAprioriJobTokenArgs {
|
||||
kinovi_version,
|
||||
apriori_job_token: &job_token,
|
||||
uuid_idempotency_token: &idempotency_str,
|
||||
maybe_external_third_party_id: order_id,
|
||||
maybe_model_type: shared.maybe_model_type,
|
||||
maybe_prompt_token: shared.maybe_prompt_token,
|
||||
maybe_wallet_ledger_entry_token,
|
||||
maybe_creator_user_token: Some(shared.user_token),
|
||||
maybe_avt_token: shared.maybe_avt_token,
|
||||
creator_ip_address: shared.ip_address,
|
||||
creator_set_visibility: Visibility::Public,
|
||||
maybe_platform_type: shared.maybe_platform_type,
|
||||
maybe_cost_estimates: shared.maybe_cost_estimates,
|
||||
maybe_debug_log_event_token: shared.maybe_debug_log_event_token,
|
||||
mysql_executor: &mut **shared.transaction,
|
||||
maybe_inference_args: None,
|
||||
phantom: Default::default(),
|
||||
}
|
||||
).await;
|
||||
|
||||
match db_result {
|
||||
Ok(token) => all_job_tokens.push(token),
|
||||
Err(err) => {
|
||||
warn!("Error inserting seedance2pro inference job (order_id={}): {:?}", order_id, err);
|
||||
if i == 0 { return Err(CommonWebError::from_error(err)); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let primary_job_token = all_job_tokens.first().cloned().ok_or_else(|| {
|
||||
error!("No inference job token was created");
|
||||
CommonWebError::server_error_with_message("No inference job token was created")
|
||||
})?;
|
||||
|
||||
Ok(InsertSeedance2proJobsResult {
|
||||
primary_job_token,
|
||||
all_job_tokens,
|
||||
})
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
pub mod insert_fal_job;
|
||||
pub mod insert_gmicloud_job;
|
||||
pub mod insert_grok_api_job;
|
||||
pub mod insert_seedance2pro_jobs;
|
||||
pub mod shared_job_args;
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
use enums::common::generation::common_model_type::CommonModelType;
|
||||
use enums::common::platform_type::PlatformType;
|
||||
use mysql_queries::queries::generic_inference::common::job_cost_estimates::JobCostEstimates;
|
||||
use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken;
|
||||
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
|
||||
use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken;
|
||||
use tokens::tokens::prompts::PromptToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
pub struct SharedJobArgs<'a, 'tx> {
|
||||
pub apriori_job_token: &'a InferenceJobToken,
|
||||
pub idempotency_token: &'a str,
|
||||
pub user_token: &'a UserToken,
|
||||
pub maybe_avt_token: Option<&'a AnonymousVisitorTrackingToken>,
|
||||
pub maybe_prompt_token: Option<&'a PromptToken>,
|
||||
pub maybe_debug_log_event_token: Option<&'a DebugLogEventToken>,
|
||||
pub maybe_model_type: Option<CommonModelType>,
|
||||
pub maybe_platform_type: Option<PlatformType>,
|
||||
pub maybe_cost_estimates: Option<JobCostEstimates>,
|
||||
pub ip_address: &'a str,
|
||||
pub transaction: &'a mut sqlx::Transaction<'tx, sqlx::MySql>,
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum KinoviAccount {
|
||||
Volcengine,
|
||||
BytePlus,
|
||||
BytePlusUltra,
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
pub mod omni_api_video_generate_handler;
|
||||
pub mod helpers;
|
||||
pub mod pipeline_v2;
|
||||
pub mod insert_db_job;
|
||||
pub mod kinovi_account;
|
||||
+474
@@ -0,0 +1,474 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::web::Json;
|
||||
use actix_web::{web, HttpRequest};
|
||||
use log::{error, info, warn};
|
||||
use sqlx::Acquire;
|
||||
|
||||
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
|
||||
use artcraft_api_defs::omni_gen::generate_response::omni_gen_video_generate_response::OmniGenVideoGenerateResponse;
|
||||
use artcraft_router::generate::generate_video::generate_video_response::GenerateVideoResponse;
|
||||
use enums::by_table::debug_logs::debug_log_type::DebugLogType;
|
||||
use enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptContextSemanticType;
|
||||
use enums::by_table::prompts::prompt_type::PromptType;
|
||||
use enums::common::generation::common_generation_mode::CommonGenerationMode;
|
||||
use enums::common::generation::common_model_type::CommonModelType;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel;
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
use enums::common::platform_type::PlatformType;
|
||||
use http_server_common::request::get_request_ip::get_request_ip;
|
||||
use mysql_queries::queries::debug_logs::insert_debug_log::{insert_debug_log, InsertDebugLogArgs};
|
||||
use mysql_queries::queries::generic_inference::api_providers::seedance2pro::insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token::KinoviVersion;
|
||||
use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_idempotency_token;
|
||||
use mysql_queries::queries::prompt_context_items::insert_batch_prompt_context_items::{
|
||||
insert_batch_prompt_context_items, InsertBatchArgs, PromptContextItem,
|
||||
};
|
||||
use mysql_queries::queries::prompts::insert_prompt::{insert_prompt, InsertPromptArgs};
|
||||
use tokens::tokens::characters::CharacterToken;
|
||||
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::endpoints::generate::common::payments_error_test::payments_error_test;
|
||||
use crate::http_server::endpoints::omni_api::generate::video::helpers::hydrate_router_request::hydrate_to_router_request;
|
||||
use crate::http_server::endpoints::omni_api::generate::video::helpers::resolve_kinovi_character_ids::resolve_kinovi_character_ids;
|
||||
use crate::http_server::endpoints::omni_api::generate::video::insert_db_job::insert_fal_job::{insert_fal_job, InsertFalJobArgs};
|
||||
use crate::http_server::endpoints::omni_api::generate::video::insert_db_job::insert_gmicloud_job::{insert_gmicloud_job, InsertGmiCloudJobArgs};
|
||||
use crate::http_server::endpoints::omni_api::generate::video::insert_db_job::insert_grok_api_job::{insert_grok_api_job, InsertGrokApiJobArgs};
|
||||
use crate::http_server::endpoints::omni_api::generate::video::insert_db_job::insert_seedance2pro_jobs::{insert_seedance2pro_jobs, InsertSeedance2proJobsArgs};
|
||||
use crate::http_server::endpoints::omni_api::generate::video::insert_db_job::shared_job_args::SharedJobArgs;
|
||||
use crate::http_server::endpoints::omni_api::generate::video::kinovi_account::KinoviAccount;
|
||||
use crate::http_server::endpoints::omni_api::generate::video::pipeline_v2::run_pipeline_v2::{run_pipeline_v2, RunPipelineV2Args};
|
||||
use crate::http_server::endpoints::omni_api::shared_utils::video::validate_video_request::validate_video_request;
|
||||
use crate::http_server::session::lookup::user_session_feature_flags::UserSessionFeatureFlags;
|
||||
use crate::http_server::user_lookup::api_keys::require_api_key_user::require_api_key_user;
|
||||
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
|
||||
use crate::state::server_state::ServerState;
|
||||
use crate::util::lookup::lookup_media_files_as_cdn_url_list_and_map::lookup_media_files_as_cdn_url_list_and_map;
|
||||
|
||||
/// Generate a video using the omni-api unified endpoint (API-key authenticated).
|
||||
#[utoipa::path(
|
||||
post,
|
||||
tag = "Omni API",
|
||||
path = "/v1/omni_api/generate/video",
|
||||
request_body = OmniGenVideoCostAndGenerateRequest,
|
||||
responses(
|
||||
(status = 200, description = "Success", body = OmniGenVideoGenerateResponse),
|
||||
(status = 400, description = "Bad input"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 402, description = "Payment required"),
|
||||
(status = 500, description = "Server error"),
|
||||
),
|
||||
)]
|
||||
pub async fn omni_api_video_generate_handler(
|
||||
http_request: HttpRequest,
|
||||
request: Json<OmniGenVideoCostAndGenerateRequest>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<OmniGenVideoGenerateResponse>, CommonWebError> {
|
||||
|
||||
info!("request: {:?}", request);
|
||||
|
||||
// Reject doomed combos (e.g. grok_imagine_video_1p5 without an image)
|
||||
// before any billable or DB-mutating work — see helper for the rules.
|
||||
validate_video_request(&request)?;
|
||||
|
||||
payments_error_test(&request.prompt.as_deref().unwrap_or(""))?;
|
||||
|
||||
let debug_log_event_token = DebugLogEventToken::generate();
|
||||
|
||||
let maybe_prompt_model_type: Option<CommonModelType> = request.model
|
||||
.as_ref()
|
||||
.map(|m| m.to_common_model_type());
|
||||
|
||||
// ==================== API KEY USER ==================== //
|
||||
|
||||
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
|
||||
|
||||
// API-key authentication (Authorization header) instead of a session cookie. Never cached.
|
||||
let api_session = require_api_key_user(&http_request, &mut *mysql_connection).await?;
|
||||
|
||||
let user_token = &api_session.user_token;
|
||||
|
||||
// ==================== MODEL ACCESS CHECK ==================== //
|
||||
|
||||
// API-key users have no per-user feature flags today.
|
||||
let user_feature_flags = UserSessionFeatureFlags::new(None);
|
||||
|
||||
// ==================== IDEMPOTENCY ==================== //
|
||||
|
||||
let idempotency_token = request.idempotency_token.as_deref()
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
if let Err(reason) = validate_idempotency_token_format(&idempotency_token) {
|
||||
return Err(CommonWebError::BadInputWithSimpleMessage(reason));
|
||||
}
|
||||
|
||||
insert_idempotency_token(&idempotency_token, &mut *mysql_connection)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
error!("Error inserting idempotency token: {:?}", err);
|
||||
CommonWebError::BadInputWithSimpleMessage("repeated idempotency token".to_string())
|
||||
})?;
|
||||
|
||||
// ==================== RESOLVE MEDIA TOKENS ==================== //
|
||||
|
||||
let mut all_tokens: Vec<MediaFileToken> = Vec::new();
|
||||
|
||||
if let Some(token) = &request.start_frame_image_media_token {
|
||||
all_tokens.push(token.clone());
|
||||
}
|
||||
if let Some(token) = &request.end_frame_image_media_token {
|
||||
all_tokens.push(token.clone());
|
||||
}
|
||||
if let Some(tokens) = &request.reference_image_media_tokens {
|
||||
all_tokens.extend(tokens.iter().cloned());
|
||||
}
|
||||
if let Some(tokens) = &request.reference_video_media_tokens {
|
||||
all_tokens.extend(tokens.iter().cloned());
|
||||
}
|
||||
if let Some(tokens) = &request.reference_audio_media_tokens {
|
||||
all_tokens.extend(tokens.iter().cloned());
|
||||
}
|
||||
|
||||
let media_file_to_url_map: Option<HashMap<MediaFileToken, String>> = if all_tokens.is_empty() {
|
||||
None
|
||||
} else {
|
||||
info!("Resolving {} media file tokens to CDN URLs", all_tokens.len());
|
||||
let resolved = lookup_media_files_as_cdn_url_list_and_map(
|
||||
&http_request,
|
||||
&mut mysql_connection,
|
||||
server_state.server_environment,
|
||||
&all_tokens,
|
||||
).await?;
|
||||
Some(resolved.token_to_url_map)
|
||||
};
|
||||
|
||||
// ==================== RESOLVE CHARACTERS ==================== //
|
||||
|
||||
let kinovi_character_id_map: Option<HashMap<CharacterToken, String>> =
|
||||
resolve_kinovi_character_ids(
|
||||
request.reference_character_tokens.as_deref(),
|
||||
&mut mysql_connection,
|
||||
).await?;
|
||||
|
||||
// ==================== HYDRATE ROUTER REQUEST ==================== //
|
||||
|
||||
let router_builder = hydrate_to_router_request(&request)?;
|
||||
|
||||
// ==================== PIPELINE DISPATCH ==================== //
|
||||
|
||||
let kinovi_account = match request.model {
|
||||
// BytePlus Ultra
|
||||
Some(CommonVideoModel::Seedance2p0BytePlusUltra) => KinoviAccount::BytePlusUltra,
|
||||
Some(CommonVideoModel::Seedance2p0BytePlusUltraFast) => KinoviAccount::BytePlusUltra,
|
||||
// BytePlus
|
||||
Some(CommonVideoModel::Seedance2p0BytePlus) => KinoviAccount::BytePlus,
|
||||
Some(CommonVideoModel::Seedance2p0BytePlusFast) => KinoviAccount::BytePlus,
|
||||
Some(CommonVideoModel::PreviewModel) => KinoviAccount::BytePlus,
|
||||
Some(CommonVideoModel::PreviewModelFast) => KinoviAccount::BytePlus,
|
||||
// Everything else goes through Volcengine
|
||||
_ => KinoviAccount::Volcengine,
|
||||
};
|
||||
|
||||
// ==================== DEBUG LOG: HTTP REQUEST ==================== //
|
||||
|
||||
if let Err(err) = insert_debug_log(InsertDebugLogArgs {
|
||||
apriori_debug_log_event_token: Some(&debug_log_event_token),
|
||||
maybe_creator_user_token: Some(user_token),
|
||||
debug_log_type: DebugLogType::HttpRequest,
|
||||
message: &serde_json::to_string(&*request).unwrap_or_default(),
|
||||
mysql_executor: &mut *mysql_connection,
|
||||
phantom: Default::default(),
|
||||
}).await {
|
||||
warn!("Failed to insert HTTP request debug log: {:?}", err);
|
||||
}
|
||||
|
||||
// NB: Release the pooled DB connection before the (slow, external) generation call so we don't
|
||||
// hold a pool slot idle while waiting on the provider — that's what starves the pool and causes
|
||||
// PoolTimedOut on unrelated endpoints. We re-acquire below to write the result.
|
||||
drop(mysql_connection);
|
||||
|
||||
let pipeline_result = run_pipeline_v2(RunPipelineV2Args {
|
||||
router_builder: &router_builder,
|
||||
server_state: &server_state,
|
||||
user_token,
|
||||
media_file_to_url_map: &media_file_to_url_map,
|
||||
kinovi_character_id_map: &kinovi_character_id_map,
|
||||
kinovi_account,
|
||||
}).await?;
|
||||
|
||||
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
|
||||
|
||||
// ==================== DEBUG LOG: FAL REQUEST ==================== //
|
||||
|
||||
if let GenerateVideoResponse::Fal(ref fal_payload) = pipeline_result.response {
|
||||
if let Some(ref outbound_request) = fal_payload.maybe_outbound_request {
|
||||
if let Err(err) = insert_debug_log(InsertDebugLogArgs {
|
||||
apriori_debug_log_event_token: Some(&debug_log_event_token),
|
||||
maybe_creator_user_token: Some(user_token),
|
||||
debug_log_type: DebugLogType::FalRequest,
|
||||
message: &format!("{:#?}", outbound_request),
|
||||
mysql_executor: &mut *mysql_connection,
|
||||
phantom: Default::default(),
|
||||
}).await {
|
||||
warn!("Failed to insert Fal request debug log: {:?}", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== DEBUG LOG: GROK API REQUEST ==================== //
|
||||
|
||||
if let GenerateVideoResponse::Grok(ref grok_payload) = pipeline_result.response {
|
||||
if let Some(ref outbound_request) = grok_payload.maybe_outbound_request {
|
||||
if let Err(err) = insert_debug_log(InsertDebugLogArgs {
|
||||
apriori_debug_log_event_token: Some(&debug_log_event_token),
|
||||
maybe_creator_user_token: Some(user_token),
|
||||
debug_log_type: DebugLogType::GrokApiRequest,
|
||||
message: &format!("{:#?}", outbound_request),
|
||||
mysql_executor: &mut *mysql_connection,
|
||||
phantom: Default::default(),
|
||||
}).await {
|
||||
warn!("Failed to insert Grok API request debug log: {:?}", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== WRITE RESULT ==================== //
|
||||
|
||||
let ip_address = get_request_ip(&http_request);
|
||||
// Omni API requests are always API-key authenticated; hardcode the platform type.
|
||||
let maybe_platform_type = Some(PlatformType::ApiKey);
|
||||
|
||||
let mut transaction = mysql_connection.begin().await.map_err(|err| {
|
||||
error!("Error starting MySQL transaction: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
// -- Prompt --
|
||||
|
||||
let prompt_token = match insert_prompt(InsertPromptArgs {
|
||||
maybe_apriori_prompt_token: None,
|
||||
prompt_type: PromptType::ArtcraftApp,
|
||||
maybe_creator_user_token: Some(user_token),
|
||||
maybe_model_type: maybe_prompt_model_type,
|
||||
maybe_generation_provider: Some(GenerationProvider::Artcraft),
|
||||
maybe_positive_prompt: request.prompt.as_deref(),
|
||||
maybe_negative_prompt: request.negative_prompt.as_deref(),
|
||||
maybe_other_args: None,
|
||||
maybe_generation_mode: Some(determine_generation_mode(&request)),
|
||||
maybe_aspect_ratio: request.aspect_ratio,
|
||||
maybe_resolution: request.resolution,
|
||||
maybe_batch_count: request.video_batch_count.map(|c| c as u8),
|
||||
maybe_generate_audio: request.generate_audio,
|
||||
maybe_duration_seconds: request.duration_seconds.map(|d| d as u32),
|
||||
creator_ip_address: &ip_address,
|
||||
mysql_executor: &mut *transaction,
|
||||
phantom: Default::default(),
|
||||
}).await {
|
||||
Ok(token) => Some(token),
|
||||
Err(err) => {
|
||||
warn!("Error inserting prompt: {:?}", err);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
// -- Prompt context items --
|
||||
|
||||
if let Some(token) = prompt_token.as_ref() {
|
||||
let mut context_items = Vec::new();
|
||||
|
||||
if let Some(media_token) = &request.start_frame_image_media_token {
|
||||
context_items.push(PromptContextItem {
|
||||
media_token: media_token.clone(),
|
||||
context_semantic_type: PromptContextSemanticType::VidStartFrame,
|
||||
});
|
||||
}
|
||||
if let Some(media_token) = &request.end_frame_image_media_token {
|
||||
context_items.push(PromptContextItem {
|
||||
media_token: media_token.clone(),
|
||||
context_semantic_type: PromptContextSemanticType::VidEndFrame,
|
||||
});
|
||||
}
|
||||
if let Some(ref_tokens) = &request.reference_image_media_tokens {
|
||||
for media_token in ref_tokens {
|
||||
context_items.push(PromptContextItem {
|
||||
media_token: media_token.clone(),
|
||||
context_semantic_type: PromptContextSemanticType::Imgref,
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some(ref_tokens) = &request.reference_video_media_tokens {
|
||||
for media_token in ref_tokens {
|
||||
context_items.push(PromptContextItem {
|
||||
media_token: media_token.clone(),
|
||||
context_semantic_type: PromptContextSemanticType::VidRef,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if !context_items.is_empty() {
|
||||
if let Err(err) = insert_batch_prompt_context_items(InsertBatchArgs {
|
||||
prompt_token: token.clone(),
|
||||
items: context_items,
|
||||
transaction: &mut transaction,
|
||||
}).await {
|
||||
warn!("Error inserting batch prompt context items: {:?}", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- Inference job --
|
||||
|
||||
let (primary_job_token, all_job_tokens) = match &pipeline_result.response {
|
||||
GenerateVideoResponse::Seedance2Pro(payload) => {
|
||||
info!("Inserting seedance2pro job(s) with token: {:?}", pipeline_result.billing.apriori_job_token);
|
||||
|
||||
let kinovi_version = match kinovi_account {
|
||||
KinoviAccount::Volcengine => KinoviVersion::Volcengine,
|
||||
KinoviAccount::BytePlus => KinoviVersion::BytePlus,
|
||||
KinoviAccount::BytePlusUltra => KinoviVersion::BytePlusUltra,
|
||||
};
|
||||
|
||||
let result = insert_seedance2pro_jobs(InsertSeedance2proJobsArgs {
|
||||
primary_order_id: &payload.order_id,
|
||||
maybe_additional_order_ids: payload.maybe_order_ids.as_deref(),
|
||||
maybe_wallet_ledger_entry_token: pipeline_result.billing.maybe_wallet_ledger_entry_token.as_ref(),
|
||||
kinovi_version,
|
||||
shared: SharedJobArgs {
|
||||
apriori_job_token: &pipeline_result.billing.apriori_job_token,
|
||||
idempotency_token: &idempotency_token,
|
||||
user_token,
|
||||
maybe_avt_token: None, // AVT tokens are web-session only; API-key callers have none.
|
||||
maybe_model_type: request.model.map(|v| v.to_common_model_type()),
|
||||
maybe_prompt_token: prompt_token.as_ref(),
|
||||
maybe_debug_log_event_token: Some(&debug_log_event_token),
|
||||
maybe_platform_type,
|
||||
maybe_cost_estimates: Some(pipeline_result.cost_estimates),
|
||||
ip_address: &ip_address,
|
||||
transaction: &mut transaction,
|
||||
},
|
||||
}).await?;
|
||||
(result.primary_job_token, result.all_job_tokens)
|
||||
}
|
||||
GenerateVideoResponse::Fal(payload) => {
|
||||
let external_id = payload.request_id.as_deref().ok_or_else(|| {
|
||||
error!("Fal generation response missing request_id");
|
||||
CommonWebError::server_error_with_message("Fal generation response missing request_id")
|
||||
})?;
|
||||
info!("Inserting fal job with token: {:?}", pipeline_result.billing.apriori_job_token);
|
||||
let token = insert_fal_job(InsertFalJobArgs {
|
||||
external_job_id: external_id,
|
||||
shared: SharedJobArgs {
|
||||
apriori_job_token: &pipeline_result.billing.apriori_job_token,
|
||||
idempotency_token: &idempotency_token,
|
||||
user_token,
|
||||
maybe_avt_token: None, // AVT tokens are web-session only; API-key callers have none.
|
||||
maybe_model_type: request.model.map(|v| v.to_common_model_type()),
|
||||
maybe_prompt_token: prompt_token.as_ref(),
|
||||
maybe_debug_log_event_token: Some(&debug_log_event_token),
|
||||
maybe_platform_type,
|
||||
maybe_cost_estimates: Some(pipeline_result.cost_estimates),
|
||||
ip_address: &ip_address,
|
||||
transaction: &mut transaction,
|
||||
},
|
||||
}).await?;
|
||||
|
||||
(
|
||||
token.clone(),
|
||||
vec![token],
|
||||
)
|
||||
}
|
||||
GenerateVideoResponse::Artcraft(payload) => {
|
||||
(
|
||||
payload.inference_job_token.clone(),
|
||||
vec![payload.inference_job_token.clone()],
|
||||
)
|
||||
}
|
||||
GenerateVideoResponse::GmiCloud(payload) => {
|
||||
info!("Inserting GmiCloud job with token: {:?}", pipeline_result.billing.apriori_job_token);
|
||||
let token = insert_gmicloud_job(InsertGmiCloudJobArgs {
|
||||
external_request_id: &payload.request_id,
|
||||
shared: SharedJobArgs {
|
||||
apriori_job_token: &pipeline_result.billing.apriori_job_token,
|
||||
idempotency_token: &idempotency_token,
|
||||
user_token,
|
||||
maybe_avt_token: None, // AVT tokens are web-session only; API-key callers have none.
|
||||
maybe_model_type: request.model.map(|v| v.to_common_model_type()),
|
||||
maybe_prompt_token: prompt_token.as_ref(),
|
||||
maybe_debug_log_event_token: Some(&debug_log_event_token),
|
||||
maybe_platform_type,
|
||||
maybe_cost_estimates: Some(pipeline_result.cost_estimates),
|
||||
ip_address: &ip_address,
|
||||
transaction: &mut transaction,
|
||||
},
|
||||
}).await?;
|
||||
|
||||
(
|
||||
token.clone(),
|
||||
vec![token],
|
||||
)
|
||||
}
|
||||
GenerateVideoResponse::Grok(payload) => {
|
||||
info!("Inserting Grok (xAI) API job with token: {:?}", pipeline_result.billing.apriori_job_token);
|
||||
let token = insert_grok_api_job(InsertGrokApiJobArgs {
|
||||
external_request_id: &payload.request_id,
|
||||
shared: SharedJobArgs {
|
||||
apriori_job_token: &pipeline_result.billing.apriori_job_token,
|
||||
idempotency_token: &idempotency_token,
|
||||
user_token,
|
||||
maybe_avt_token: None, // AVT tokens are web-session only; API-key callers have none.
|
||||
maybe_model_type: request.model.map(|v| v.to_common_model_type()),
|
||||
maybe_prompt_token: prompt_token.as_ref(),
|
||||
maybe_debug_log_event_token: Some(&debug_log_event_token),
|
||||
maybe_platform_type,
|
||||
maybe_cost_estimates: Some(pipeline_result.cost_estimates),
|
||||
ip_address: &ip_address,
|
||||
transaction: &mut transaction,
|
||||
},
|
||||
}).await?;
|
||||
|
||||
(
|
||||
token.clone(),
|
||||
vec![token],
|
||||
)
|
||||
}
|
||||
other => {
|
||||
error!("Unexpected generation response variant: {:?}", other);
|
||||
return Err(CommonWebError::server_error_with_message("Unexpected generation response"));
|
||||
}
|
||||
};
|
||||
|
||||
transaction.commit().await.map_err(|err| {
|
||||
error!("Error committing transaction: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
Ok(Json(OmniGenVideoGenerateResponse {
|
||||
success: true,
|
||||
inference_job_token: primary_job_token,
|
||||
all_job_tokens,
|
||||
}))
|
||||
}
|
||||
|
||||
fn determine_generation_mode(request: &OmniGenVideoCostAndGenerateRequest) -> CommonGenerationMode {
|
||||
let has_keyframe = request.start_frame_image_media_token.is_some()
|
||||
|| request.end_frame_image_media_token.is_some();
|
||||
|
||||
if has_keyframe {
|
||||
return CommonGenerationMode::Keyframe;
|
||||
}
|
||||
|
||||
let has_reference = request.reference_image_media_tokens.as_ref().is_some_and(|t| !t.is_empty())
|
||||
|| request.reference_video_media_tokens.as_ref().is_some_and(|t| !t.is_empty())
|
||||
|| request.reference_audio_media_tokens.as_ref().is_some_and(|t| !t.is_empty())
|
||||
|| request.reference_character_tokens.as_ref().is_some_and(|t| !t.is_empty());
|
||||
|
||||
if has_reference {
|
||||
return CommonGenerationMode::Reference;
|
||||
}
|
||||
|
||||
CommonGenerationMode::Text
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
pub mod run_pipeline_v2;
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
use std::convert::TryFrom;
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use log::{error, info, warn};
|
||||
use artcraft_router::api::router_video_model::RouterVideoModel;
|
||||
use artcraft_router::api::router_provider::RouterProvider;
|
||||
use artcraft_router::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use artcraft_router::generate::generate_video::generate_video_response::GenerateVideoResponse;
|
||||
use artcraft_router::generate::generate_video::video_generation_draft_context::VideoGenerationDraftContext;
|
||||
use artcraft_router::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use tokens::tokens::characters::CharacterToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::endpoint_helpers::refund_wallet_after_api_failure::refund_wallet_after_api_failure;
|
||||
use crate::http_server::endpoints::omni_api::generate::video::helpers::bill_wallet::bill_wallet;
|
||||
use crate::http_server::endpoints::omni_api::generate::video::helpers::build_router_client::build_router_client;
|
||||
use crate::http_server::endpoints::omni_api::generate::video::helpers::pipeline_result::PipelineResult;
|
||||
use crate::http_server::endpoints::omni_api::generate::video::helpers::resolve_media_tokens_to_urls::resolve_media_tokens_to_urls;
|
||||
use mysql_queries::queries::generic_inference::common::job_cost_estimates::JobCostEstimates;
|
||||
use crate::http_server::endpoints::omni_api::generate::video::kinovi_account::KinoviAccount;
|
||||
use crate::http_server::endpoints::omni_api::shared_utils::map_seedance2pro_router_error::map_router_error_to_web_error;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
pub struct RunPipelineV2Args<'a> {
|
||||
pub router_builder: &'a GenerateVideoRequestBuilder,
|
||||
pub server_state: &'a ServerState,
|
||||
pub user_token: &'a UserToken,
|
||||
pub media_file_to_url_map: &'a Option<HashMap<MediaFileToken, String>>,
|
||||
pub kinovi_character_id_map: &'a Option<HashMap<CharacterToken, String>>,
|
||||
pub kinovi_account: KinoviAccount,
|
||||
}
|
||||
|
||||
// NB: This pipeline does an external generation call (`upload_and_generate`) that can take many
|
||||
// seconds. It deliberately does NOT hold a pooled DB connection across that call — it acquires
|
||||
// short-lived connections only for the billing and (on failure) refund writes. Holding a pooled
|
||||
// connection across the external call is what starves the pool and causes `PoolTimedOut`.
|
||||
pub async fn run_pipeline_v2(args: RunPipelineV2Args<'_>) -> Result<PipelineResult, CommonWebError> {
|
||||
let RunPipelineV2Args {
|
||||
router_builder,
|
||||
server_state,
|
||||
user_token,
|
||||
media_file_to_url_map,
|
||||
kinovi_character_id_map,
|
||||
kinovi_account,
|
||||
} = args;
|
||||
|
||||
let mut router_builder = router_builder.clone();
|
||||
|
||||
match router_builder.model {
|
||||
RouterVideoModel::PreviewModel |
|
||||
RouterVideoModel::Seedance2p0BytePlus |
|
||||
RouterVideoModel::Seedance2p0BytePlusUltra => {
|
||||
router_builder.model = RouterVideoModel::Seedance2p0;
|
||||
},
|
||||
RouterVideoModel::PreviewModelFast |
|
||||
RouterVideoModel::Seedance2p0BytePlusFast |
|
||||
RouterVideoModel::Seedance2p0BytePlusUltraFast => {
|
||||
router_builder.model = RouterVideoModel::Seedance2p0Fast;
|
||||
},
|
||||
_ => {}, // Fall-through
|
||||
}
|
||||
|
||||
let provider = match router_builder.model {
|
||||
RouterVideoModel::HappyHorse1p0 => RouterProvider::Seedance2Pro,
|
||||
RouterVideoModel::Seedance2p0 => RouterProvider::Seedance2Pro,
|
||||
RouterVideoModel::Seedance2p0Fast => RouterProvider::Seedance2Pro,
|
||||
//RouterVideoModel::Seedance2p0Ultra => RouterProvider::GmiCloud,
|
||||
//RouterVideoModel::Seedance2p0UltraFast => RouterProvider::GmiCloud,
|
||||
RouterVideoModel::GrokImagineVideo => RouterProvider::GrokApi,
|
||||
RouterVideoModel::GrokImagineVideo1p5 => RouterProvider::GrokApi,
|
||||
_ => RouterProvider::Fal,
|
||||
};
|
||||
|
||||
// 1. Build execution request
|
||||
let mut exec_builder = router_builder.clone();
|
||||
exec_builder.provider = provider;
|
||||
|
||||
// Fal, GmiCloud, and Grok (xAI) take image URLs directly, not media file tokens.
|
||||
// Resolve tokens to URLs before building.
|
||||
if matches!(provider, RouterProvider::Fal | RouterProvider::GmiCloud | RouterProvider::GrokApi) {
|
||||
resolve_media_tokens_to_urls(&mut exec_builder, media_file_to_url_map.as_ref());
|
||||
}
|
||||
|
||||
let draft_or_request = exec_builder.build2()
|
||||
.map_err(|e| {
|
||||
warn!("Failed to build2 for v2 pipeline: {}", e);
|
||||
CommonWebError::from_error(e)
|
||||
})?;
|
||||
|
||||
// 2. Calculate cost.
|
||||
// For Artcraft-billable models, swap provider to Artcraft so credits = cents.
|
||||
// For GmiCloud, use the execution request's cost directly (no Artcraft equivalent).
|
||||
let system_cost_estimate = {
|
||||
let mut cost_builder = router_builder.clone();
|
||||
cost_builder.provider = RouterProvider::Artcraft;
|
||||
|
||||
cost_builder.build2()
|
||||
.map_err(|e| {
|
||||
warn!("Failed to build2 cost estimate for v2: {}", e);
|
||||
CommonWebError::from_error(e)
|
||||
})?
|
||||
.estimate_cost()
|
||||
.map_err(|e| {
|
||||
warn!("Failed to estimate cost for v2: {}", e);
|
||||
CommonWebError::from_error(e)
|
||||
})?
|
||||
};
|
||||
|
||||
let cost = system_cost_estimate.cost_in_credits.unwrap_or(0);
|
||||
|
||||
// Provider-side estimate (what the fulfilling provider charges us). The
|
||||
// router defers to the underlying provider crates (seedance2pro_client,
|
||||
// gmicloud_client, grok_api_client, the fal pricing modules, etc.) per
|
||||
// request variant. Bookkeeping only — failures must not block generation.
|
||||
let maybe_provider_cost_estimate = match draft_or_request.estimate_cost() {
|
||||
Ok(estimate) => Some(estimate),
|
||||
Err(err) => {
|
||||
warn!("Failed to estimate provider cost for v2 video: {}", err);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let cost_estimates = JobCostEstimates {
|
||||
maybe_external_third_party_cost_credits: maybe_provider_cost_estimate.as_ref()
|
||||
.and_then(|e| e.cost_in_credits)
|
||||
.and_then(|v| u32::try_from(v).ok()),
|
||||
maybe_external_third_party_cost_usd_cents: maybe_provider_cost_estimate.as_ref()
|
||||
.and_then(|e| e.cost_in_usd_cents)
|
||||
.and_then(|v| u32::try_from(v).ok()),
|
||||
maybe_system_cost_credits: system_cost_estimate.cost_in_credits
|
||||
.and_then(|v| u32::try_from(v).ok()),
|
||||
maybe_system_cost_usd_cents: system_cost_estimate.cost_in_usd_cents
|
||||
.and_then(|v| u32::try_from(v).ok()),
|
||||
};
|
||||
|
||||
info!("v2 estimated cost: {} credits (estimates: {:?})", cost, cost_estimates);
|
||||
|
||||
// 3. Bill wallet (short-lived connection — released before the external call below).
|
||||
let billing = {
|
||||
let mut billing_connection = server_state.mysql_pool.acquire().await
|
||||
.map_err(|err| {
|
||||
error!("Failed to acquire MySQL connection for billing: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
bill_wallet(user_token, cost, &mut billing_connection).await?
|
||||
};
|
||||
|
||||
// 4. Upload media (if draft) and generate video.
|
||||
// The entire block is wrapped so Kinovi failures trigger a refund.
|
||||
// NB: No pooled DB connection is held across this call.
|
||||
let result = upload_and_generate(
|
||||
draft_or_request,
|
||||
server_state,
|
||||
media_file_to_url_map.as_ref(),
|
||||
kinovi_character_id_map.as_ref(),
|
||||
kinovi_account,
|
||||
).await;
|
||||
|
||||
// 5. On failure, refund wallet for Kinovi requests.
|
||||
if let Err(ref err) = result {
|
||||
if matches!(provider, RouterProvider::Seedance2Pro) {
|
||||
if let Some(ledger_entry_token) = billing.maybe_wallet_ledger_entry_token.as_ref() {
|
||||
warn!("Kinovi v2 generation failed, issuing refund for {}: {:?}", ledger_entry_token.as_str(), err);
|
||||
|
||||
match server_state.mysql_pool.acquire().await {
|
||||
Ok(mut refund_connection) => {
|
||||
if let Err(refund_err) = refund_wallet_after_api_failure(ledger_entry_token, &mut refund_connection).await {
|
||||
error!("Failed to refund wallet after Kinovi v2 failure: {:?}", refund_err);
|
||||
}
|
||||
}
|
||||
Err(acquire_err) => {
|
||||
error!("Failed to acquire MySQL connection to refund wallet after Kinovi v2 failure: {:?}", acquire_err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let response = result?;
|
||||
|
||||
info!("v2 generation response: {:?}", response);
|
||||
|
||||
Ok(PipelineResult { billing, response, cost_estimates })
|
||||
}
|
||||
|
||||
/// Finalize the draft (uploading media if needed), then send the generation request.
|
||||
///
|
||||
/// This is the block that gets refunded on failure for Kinovi providers.
|
||||
async fn upload_and_generate(
|
||||
draft_or_request: VideoGenerationDraftOrRequest,
|
||||
server_state: &ServerState,
|
||||
media_file_urls_by_token: Option<&HashMap<MediaFileToken, String>>,
|
||||
kinovi_character_ids: Option<&HashMap<CharacterToken, String>>,
|
||||
kinovi_account: KinoviAccount,
|
||||
) -> Result<GenerateVideoResponse, CommonWebError> {
|
||||
|
||||
let provider = draft_or_request.get_provider();
|
||||
let client = build_router_client(provider, server_state, kinovi_account)?;
|
||||
|
||||
let video_request = match draft_or_request {
|
||||
VideoGenerationDraftOrRequest::Request(request) => request,
|
||||
VideoGenerationDraftOrRequest::Draft(draft) => {
|
||||
let draft_context = VideoGenerationDraftContext {
|
||||
client: Some(&client),
|
||||
media_file_to_artcraft_url_map: media_file_urls_by_token,
|
||||
character_token_to_kinovi_id_map: kinovi_character_ids,
|
||||
};
|
||||
|
||||
draft.finalize(draft_context)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!("Failed to finalize v2 draft: {:?}", err);
|
||||
map_router_error_to_web_error(err)
|
||||
})?
|
||||
}
|
||||
};
|
||||
|
||||
video_request.send_request(&client)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!("v2 video generation failed: {:?}", err);
|
||||
map_router_error_to_web_error(err)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod generate;
|
||||
pub mod shared_utils;
|
||||
pub mod upload;
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
//! Maps artcraft router errors to web errors for the omni_gen pipelines.
|
||||
//!
|
||||
//! Known Kinovi (Seedance2Pro) bad requests (content violations, over-long
|
||||
//! prompts, too many urls) become 400s with a user-facing message. Everything
|
||||
//! else is unanticipated and stays a 500.
|
||||
|
||||
use artcraft_router::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use artcraft_router::errors::provider_error::ProviderError;
|
||||
use seedance2pro_client::error::seedance2pro_bad_request_api_error::Seedance2ProBadRequestApiError;
|
||||
use seedance2pro_client::error::seedance2pro_error::Seedance2ProError;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
|
||||
/// Map an artcraft router error to a web error, unwrapping Kinovi bad requests.
|
||||
pub fn map_router_error_to_web_error(error: ArtcraftRouterError) -> CommonWebError {
|
||||
match error {
|
||||
ArtcraftRouterError::Provider(
|
||||
ProviderError::Seedance2Pro(Seedance2ProError::ApiBadRequest(bad_request))
|
||||
) => {
|
||||
CommonWebError::BadInputWithSimpleMessage(user_facing_bad_request_message(&bad_request))
|
||||
}
|
||||
other => CommonWebError::from_error(other),
|
||||
}
|
||||
}
|
||||
|
||||
fn user_facing_bad_request_message(error: &Seedance2ProBadRequestApiError) -> String {
|
||||
match error {
|
||||
Seedance2ProBadRequestApiError::VideoGenerationViolation { .. } => {
|
||||
"The generation request was flagged as a content violation. \
|
||||
Please adjust your prompt or input media and try again.".to_string()
|
||||
}
|
||||
Seedance2ProBadRequestApiError::PromptIsTooLong { .. } => {
|
||||
"The prompt is too long. Please shorten it below 10,000 characters and try again.".to_string()
|
||||
}
|
||||
Seedance2ProBadRequestApiError::TooManyUrls { .. } => {
|
||||
"Too many input files were attached. \
|
||||
Please remove some and try again.".to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use actix_web::ResponseError;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn kinovi_bad_requests_become_400s_with_user_facing_messages() {
|
||||
let cases = [
|
||||
(
|
||||
Seedance2ProBadRequestApiError::VideoGenerationViolation { raw_body: "{}".to_string() },
|
||||
"content violation",
|
||||
),
|
||||
(
|
||||
Seedance2ProBadRequestApiError::PromptIsTooLong { raw_body: "{}".to_string() },
|
||||
"prompt is too long",
|
||||
),
|
||||
(
|
||||
Seedance2ProBadRequestApiError::TooManyUrls { raw_body: "{}".to_string() },
|
||||
"Too many input files",
|
||||
),
|
||||
];
|
||||
|
||||
for (bad_request, expected_message_fragment) in cases {
|
||||
let error = map_router_error_to_web_error(
|
||||
ArtcraftRouterError::Provider(
|
||||
ProviderError::Seedance2Pro(
|
||||
Seedance2ProError::ApiBadRequest(bad_request))));
|
||||
|
||||
assert_eq!(error.status_code().as_u16(), 400);
|
||||
match error {
|
||||
CommonWebError::BadInputWithSimpleMessage(message) => {
|
||||
assert!(
|
||||
message.contains(expected_message_fragment),
|
||||
"message {:?} should contain {:?}", message, expected_message_fragment);
|
||||
}
|
||||
other => panic!("Expected BadInputWithSimpleMessage, got: {:?}", other),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_router_errors_stay_500s() {
|
||||
let error = map_router_error_to_web_error(
|
||||
ArtcraftRouterError::UnsupportedModel("some_model".to_string()));
|
||||
|
||||
assert_eq!(error.status_code().as_u16(), 500);
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
pub mod map_seedance2pro_router_error;
|
||||
pub mod video;
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
pub mod validate_video_request;
|
||||
mod validate_when_image_required;
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::endpoints::omni_api::shared_utils::video::validate_when_image_required::validate_when_image_required;
|
||||
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
|
||||
|
||||
/// Validate requests before they incur user costs or send API requests
|
||||
pub fn validate_video_request(
|
||||
request: &OmniGenVideoCostAndGenerateRequest,
|
||||
) -> Result<(), CommonWebError> {
|
||||
validate_when_image_required(request)?;
|
||||
Ok(())
|
||||
}
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
|
||||
/// xAI's `grok-imagine-video-1.5-preview` model rejects text-to-video at
|
||||
/// the upstream API. Reject the request at the handler layer so callers see
|
||||
/// a clean 400 with an actionable message and we don't charge their wallet
|
||||
/// for an inference job we know will fail.
|
||||
pub (super) fn validate_when_image_required(
|
||||
request: &OmniGenVideoCostAndGenerateRequest,
|
||||
) -> Result<(), CommonWebError> {
|
||||
match request.model {
|
||||
Some(CommonVideoModel::GrokImagineVideo1p5) => {}, // Fall-through
|
||||
_ => return Ok(()),
|
||||
}
|
||||
|
||||
let requires_image = matches!(request.model, Some(CommonVideoModel::GrokImagineVideo1p5));
|
||||
|
||||
if !requires_image {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let has_start_frame = request.start_frame_image_media_token.is_some();
|
||||
|
||||
let has_reference_images = request
|
||||
.reference_image_media_tokens
|
||||
.as_ref()
|
||||
.is_some_and(|v| !v.is_empty());
|
||||
|
||||
if has_start_frame || has_reference_images {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(CommonWebError::BadInputWithSimpleMessage(
|
||||
"Image is required".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
fn base_request() -> OmniGenVideoCostAndGenerateRequest {
|
||||
OmniGenVideoCostAndGenerateRequest {
|
||||
idempotency_token: Some("a1b2c3d4-e5f6-7890-abcd-ef1234567890".to_string()),
|
||||
model: None,
|
||||
prompt: Some("test".to_string()),
|
||||
negative_prompt: None,
|
||||
start_frame_image_media_token: None,
|
||||
end_frame_image_media_token: None,
|
||||
reference_image_media_tokens: None,
|
||||
reference_video_media_tokens: None,
|
||||
reference_audio_media_tokens: None,
|
||||
reference_character_tokens: None,
|
||||
resolution: None,
|
||||
aspect_ratio: None,
|
||||
quality: None,
|
||||
duration_seconds: Some(5),
|
||||
video_batch_count: Some(1),
|
||||
generate_audio: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn token(id: &str) -> MediaFileToken {
|
||||
MediaFileToken::new(id.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passes_when_model_is_none() {
|
||||
let req = base_request();
|
||||
assert!(validate_when_image_required(&req).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passes_for_other_models_without_image() {
|
||||
// Only v1.5 needs the gate; other models can do text-to-video.
|
||||
let req = OmniGenVideoCostAndGenerateRequest {
|
||||
model: Some(CommonVideoModel::Seedance2p0),
|
||||
..base_request()
|
||||
};
|
||||
assert!(validate_when_image_required(&req).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_v1p5_with_no_image_inputs() {
|
||||
let req = OmniGenVideoCostAndGenerateRequest {
|
||||
model: Some(CommonVideoModel::GrokImagineVideo1p5),
|
||||
..base_request()
|
||||
};
|
||||
let err = validate_when_image_required(&req).expect_err("should reject");
|
||||
match err {
|
||||
CommonWebError::BadInputWithSimpleMessage(msg) => {
|
||||
assert_eq!(msg, "Image is required");
|
||||
}
|
||||
other => panic!("expected BadInputWithSimpleMessage, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_v1p5_with_empty_reference_list_and_no_start_frame() {
|
||||
let req = OmniGenVideoCostAndGenerateRequest {
|
||||
model: Some(CommonVideoModel::GrokImagineVideo1p5),
|
||||
reference_image_media_tokens: Some(vec![]),
|
||||
..base_request()
|
||||
};
|
||||
assert!(matches!(
|
||||
validate_when_image_required(&req),
|
||||
Err(CommonWebError::BadInputWithSimpleMessage(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_v1p5_with_start_frame_only() {
|
||||
let req = OmniGenVideoCostAndGenerateRequest {
|
||||
model: Some(CommonVideoModel::GrokImagineVideo1p5),
|
||||
start_frame_image_media_token: Some(token("mf_start")),
|
||||
..base_request()
|
||||
};
|
||||
assert!(validate_when_image_required(&req).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_v1p5_with_reference_images_only() {
|
||||
let req = OmniGenVideoCostAndGenerateRequest {
|
||||
model: Some(CommonVideoModel::GrokImagineVideo1p5),
|
||||
reference_image_media_tokens: Some(vec![token("mf_a"), token("mf_b")]),
|
||||
..base_request()
|
||||
};
|
||||
assert!(validate_when_image_required(&req).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_v1p5_with_both_start_frame_and_reference_images() {
|
||||
let req = OmniGenVideoCostAndGenerateRequest {
|
||||
model: Some(CommonVideoModel::GrokImagineVideo1p5),
|
||||
start_frame_image_media_token: Some(token("mf_start")),
|
||||
reference_image_media_tokens: Some(vec![token("mf_a")]),
|
||||
..base_request()
|
||||
};
|
||||
assert!(validate_when_image_required(&req).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_frame_alone_does_not_count_as_image_input() {
|
||||
// end_frame_image_media_token is technically an image, but xAI's v1.5
|
||||
// wire shape only uses start_frame / reference_images. end_frame
|
||||
// shouldn't satisfy the gate.
|
||||
let req = OmniGenVideoCostAndGenerateRequest {
|
||||
model: Some(CommonVideoModel::GrokImagineVideo1p5),
|
||||
end_frame_image_media_token: Some(token("mf_end")),
|
||||
..base_request()
|
||||
};
|
||||
assert!(validate_when_image_required(&req).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod omni_upload_audio_media_file_handler;
|
||||
pub mod omni_upload_image_media_file_handler;
|
||||
pub mod omni_upload_video_media_file_handler;
|
||||
+327
@@ -0,0 +1,327 @@
|
||||
use std::collections::HashSet;
|
||||
use std::io::Read;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_multipart::form::tempfile::TempFile;
|
||||
use actix_multipart::form::text::Text;
|
||||
use actix_multipart::form::MultipartForm;
|
||||
use actix_web::web::Json;
|
||||
use actix_web::{web, HttpRequest};
|
||||
use log::{error, info, warn};
|
||||
use once_cell::sync::Lazy;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use artcraft_api_defs::omni_api::omni_upload_audio::OmniUploadAudioMediaFileSuccessResponse;
|
||||
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
|
||||
use enums::by_table::media_files::media_file_class::MediaFileClass;
|
||||
use enums::by_table::media_files::media_file_type::MediaFileType;
|
||||
use enums::common::visibility::Visibility;
|
||||
use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes;
|
||||
use http_server_common::request::get_request_ip::get_request_ip;
|
||||
use media::decode_basic_audio_info::decode_basic_audio_bytes_info;
|
||||
use mimetypes::mimetype_for_bytes::get_mimetype_for_bytes;
|
||||
use mimetypes::mimetype_to_extension::mimetype_to_extension;
|
||||
use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_idempotency_token;
|
||||
use mysql_queries::queries::media_files::create::specialized_insert::insert_media_file_from_file_upload::{insert_media_file_from_file_upload, InsertMediaFileFromUploadArgs, UploadType};
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::endpoints::media_files::upload::upload_error::MediaFileUploadError;
|
||||
use crate::http_server::endpoints::media_files::upload::common_utils::try_parse_generation_provider::try_parse_generation_provider;
|
||||
use crate::http_server::user_lookup::api_keys::require_api_key_user::require_api_key_user;
|
||||
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
/// Form-multipart request fields.
|
||||
///
|
||||
/// IF VIEWING DOCS, PLEASE SEE BOTTOM OF PAGE `OmniUploadAudioMediaFileForm` (Under "Schema") FOR DETAILS ON FIELDS AND NULLABILITY.
|
||||
#[derive(MultipartForm, ToSchema)]
|
||||
#[multipart(duplicate_field = "deny")]
|
||||
pub struct OmniUploadAudioMediaFileForm {
|
||||
/// UUID for request idempotency
|
||||
#[multipart(limit = "2 KiB")]
|
||||
#[schema(value_type = String, format = Binary)]
|
||||
uuid_idempotency_token: Text<String>,
|
||||
|
||||
/// The uploaded file
|
||||
#[multipart(limit = "512 MiB")]
|
||||
#[schema(value_type = Vec<u8>, format = Binary)]
|
||||
file: TempFile,
|
||||
|
||||
/// Optional: Title (name) of the scene
|
||||
#[multipart(limit = "2 KiB")]
|
||||
#[schema(value_type = Option<String>, format = Binary)]
|
||||
maybe_title: Option<Text<String>>,
|
||||
|
||||
/// Optional: Visibility of the scene
|
||||
#[multipart(limit = "2 KiB")]
|
||||
#[schema(value_type = Option<Visibility>, format = Binary)]
|
||||
maybe_visibility: Option<Text<Visibility>>,
|
||||
|
||||
/// Optional: The third-party generation provider (e.g. "fal", "replicate").
|
||||
/// If set, `is_user_upload` will be false and `is_intermediate_system_file` will be forced false.
|
||||
#[multipart(limit = "2 KiB")]
|
||||
#[schema(value_type = Option<String>, format = Binary)]
|
||||
maybe_generation_provider: Option<Text<String>>,
|
||||
}
|
||||
|
||||
static ALLOWED_MIME_TYPES : Lazy<HashSet<&'static str>> = Lazy::new(|| {
|
||||
HashSet::from([
|
||||
// Audio
|
||||
"audio/aac",
|
||||
"audio/m4a",
|
||||
"audio/mpeg",
|
||||
"audio/ogg",
|
||||
"audio/opus",
|
||||
"audio/x-flac",
|
||||
"audio/x-wav",
|
||||
// Mixed
|
||||
"audio/mp4", // iPhone seems to upload these as audio
|
||||
// Video
|
||||
"video/mp4",
|
||||
"video/webm",
|
||||
])
|
||||
});
|
||||
|
||||
/// API-key authenticated audio upload (Omni API). Identity is read from the `Authorization` header
|
||||
/// API key rather than a session cookie.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
tag = "Omni API",
|
||||
path = "/v1/omni_api/upload/audio",
|
||||
responses(
|
||||
(status = 200, description = "Success Update", body = OmniUploadAudioMediaFileSuccessResponse),
|
||||
(status = 400, description = "Bad input", body = MediaFileUploadError),
|
||||
(status = 401, description = "Not authorized", body = MediaFileUploadError),
|
||||
(status = 429, description = "Too many requests", body = MediaFileUploadError),
|
||||
(status = 500, description = "Server error", body = MediaFileUploadError),
|
||||
),
|
||||
params(
|
||||
(
|
||||
"request" = OmniUploadAudioMediaFileForm,
|
||||
description = "IF VIEWING DOCS, PLEASE SEE BOTTOM OF PAGE `OmniUploadAudioMediaFileForm` (Under 'Schema') FOR DETAILS ON FIELDS AND NULLABILITY."
|
||||
),
|
||||
)
|
||||
)]
|
||||
pub async fn omni_upload_audio_media_file_handler(
|
||||
http_request: HttpRequest,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
MultipartForm(mut form): MultipartForm<OmniUploadAudioMediaFileForm>,
|
||||
) -> Result<Json<OmniUploadAudioMediaFileSuccessResponse>, MediaFileUploadError> {
|
||||
|
||||
let mut mysql_connection = server_state.mysql_pool
|
||||
.acquire()
|
||||
.await
|
||||
.map_err(|err| {
|
||||
error!("MySql pool error: {:?}", err);
|
||||
MediaFileUploadError::ServerError
|
||||
})?;
|
||||
|
||||
// ==================== 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`.
|
||||
let api_session = require_api_key_user(&http_request, &mut *mysql_connection)
|
||||
.await
|
||||
.map_err(map_api_key_auth_error)?;
|
||||
|
||||
let maybe_user_token = Some(&api_session.user_token);
|
||||
|
||||
// ==================== RATE LIMIT ==================== //
|
||||
|
||||
let rate_limiter = &server_state.redis_rate_limiters.file_upload_logged_in;
|
||||
|
||||
if let Err(_err) = rate_limiter.rate_limit_request(&http_request).await {
|
||||
return Err(MediaFileUploadError::RateLimited);
|
||||
}
|
||||
|
||||
// ==================== HANDLE IDEMPOTENCY ==================== //
|
||||
|
||||
let uuid_idempotency_token = form.uuid_idempotency_token.as_ref();
|
||||
|
||||
if let Err(reason) = validate_idempotency_token_format(uuid_idempotency_token) {
|
||||
return Err(MediaFileUploadError::BadInput(reason));
|
||||
}
|
||||
|
||||
insert_idempotency_token(uuid_idempotency_token, &mut *mysql_connection)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
error!("Error inserting idempotency token: {:?}", err);
|
||||
MediaFileUploadError::BadInput("invalid idempotency token".to_string())
|
||||
})?;
|
||||
|
||||
// ==================== UPLOAD METADATA ==================== //
|
||||
|
||||
let maybe_title = form.maybe_title
|
||||
.map(|title| title.trim().to_string())
|
||||
.filter(|title| !title.is_empty());
|
||||
|
||||
let creator_set_visibility = form.maybe_visibility
|
||||
.map(|visibility| visibility.0)
|
||||
.unwrap_or(Visibility::default());
|
||||
|
||||
// ==================== USER DATA ==================== //
|
||||
|
||||
let ip_address = get_request_ip(&http_request);
|
||||
|
||||
// ==================== FILE VALIDATION ==================== //
|
||||
|
||||
let mut file_bytes = Vec::new();
|
||||
form.file.file.read_to_end(&mut file_bytes)
|
||||
.map_err(|e| {
|
||||
error!("Problem reading file: {:?}", e);
|
||||
MediaFileUploadError::ServerError
|
||||
})?;
|
||||
|
||||
let mimetype = get_mimetype_for_bytes(file_bytes.as_ref())
|
||||
.map(|mimetype| mimetype.to_string())
|
||||
.ok_or_else(|| {
|
||||
warn!("Could not determine mimetype for file");
|
||||
MediaFileUploadError::BadInput("Could not determine mimetype for file".to_string())
|
||||
})?;
|
||||
|
||||
if !ALLOWED_MIME_TYPES.contains(mimetype.as_str()) {
|
||||
// NB: Don't let our error message inject malicious strings
|
||||
let filtered_mimetype = mimetype
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii())
|
||||
.filter(|c| c.is_alphanumeric() || *c == '/')
|
||||
.collect::<String>();
|
||||
return Err(MediaFileUploadError::BadInput(format!("unpermitted mime type: {}", &filtered_mimetype)));
|
||||
}
|
||||
|
||||
// ==================== DURATION DETECTION ==================== //
|
||||
|
||||
let do_audio_decode = match mimetype.as_ref() {
|
||||
// TODO: Revisit when Safari can send us this metadata consistently
|
||||
"audio/mp4" | "video/mp4" => false,
|
||||
// Symphonia is currently broken for Firefox's opus; skip decoding it.
|
||||
"audio/opus" => false,
|
||||
// Also, don't decode images
|
||||
"image/jpeg" => false,
|
||||
"image/png" => false,
|
||||
"image/webp" => false,
|
||||
_ => true,
|
||||
};
|
||||
|
||||
let mut maybe_duration_millis = None;
|
||||
|
||||
if do_audio_decode {
|
||||
let basic_info = decode_basic_audio_bytes_info(
|
||||
file_bytes.as_ref(),
|
||||
Some(&mimetype),
|
||||
None
|
||||
).map_err(|e| {
|
||||
warn!("file decoding error: {:?}", e);
|
||||
MediaFileUploadError::BadInput("could not decode file".to_string())
|
||||
})?;
|
||||
|
||||
maybe_duration_millis = basic_info.duration_millis;
|
||||
}
|
||||
|
||||
// ==================== OTHER FILE METADATA ==================== //
|
||||
|
||||
let maybe_filename = form.file.file_name.as_deref()
|
||||
.as_deref()
|
||||
.map(|filename| PathBuf::from(filename));
|
||||
|
||||
let extension = mimetype_to_extension(&mimetype)
|
||||
.or_else(|| {
|
||||
maybe_filename
|
||||
.as_ref()
|
||||
.and_then(|filename| filename.extension())
|
||||
.and_then(|ext| ext.to_str())
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
warn!("Could not determine file extension for mimetype: {}", &mimetype);
|
||||
MediaFileUploadError::ServerError
|
||||
})?;
|
||||
|
||||
let extension = format!(".{extension}"); // NB: needs dot prefix
|
||||
|
||||
let file_size_bytes = file_bytes.len();
|
||||
|
||||
let hash = sha256_hash_bytes(&file_bytes)
|
||||
.map_err(|io_error| {
|
||||
error!("Problem hashing bytes: {:?}", io_error);
|
||||
MediaFileUploadError::ServerError
|
||||
})?;
|
||||
|
||||
// ==================== UPLOAD AND SAVE ==================== //
|
||||
|
||||
const PREFIX : Option<&str> = Some("aud_");
|
||||
|
||||
let public_upload_path = MediaFileBucketPath::generate_new(PREFIX, Some(&extension));
|
||||
|
||||
info!("Uploading media to bucket path: {}", public_upload_path.get_full_object_path_str());
|
||||
|
||||
server_state.public_bucket_client.upload_file_with_content_type(
|
||||
public_upload_path.get_full_object_path_str(),
|
||||
file_bytes.as_ref(),
|
||||
&mimetype)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!("Upload media bytes to bucket error: {:?}", e);
|
||||
MediaFileUploadError::ServerError
|
||||
})?;
|
||||
|
||||
let maybe_generation_provider = form.maybe_generation_provider
|
||||
.as_ref()
|
||||
.and_then(|text| try_parse_generation_provider(text.as_ref()));
|
||||
|
||||
let upload_type = if maybe_generation_provider.is_some() {
|
||||
UploadType::ThirdPartyInference
|
||||
} else {
|
||||
UploadType::Filesystem
|
||||
};
|
||||
|
||||
let (token, record_id) = insert_media_file_from_file_upload(InsertMediaFileFromUploadArgs {
|
||||
maybe_media_class: Some(MediaFileClass::Audio),
|
||||
media_file_type: MediaFileType::Audio,
|
||||
maybe_creator_user_token: maybe_user_token,
|
||||
// NB: AVT (anonymous visitor) tokens are a web-session concept; API-key callers have none.
|
||||
maybe_creator_anonymous_visitor_token: None,
|
||||
creator_ip_address: &ip_address,
|
||||
creator_set_visibility,
|
||||
upload_type,
|
||||
maybe_engine_category: None,
|
||||
maybe_animation_type: None,
|
||||
maybe_prompt_token: None,
|
||||
maybe_batch_token: None,
|
||||
maybe_mime_type: Some(&mimetype),
|
||||
file_size_bytes: file_size_bytes as u64,
|
||||
maybe_duration_millis,
|
||||
sha256_checksum: &hash,
|
||||
maybe_title: maybe_title.as_deref(),
|
||||
maybe_scene_source_media_file_token: None,
|
||||
is_intermediate_system_file: false, // NB: is_user_upload = true
|
||||
maybe_generation_provider,
|
||||
public_bucket_directory_hash: public_upload_path.get_object_hash(),
|
||||
maybe_public_bucket_prefix: PREFIX,
|
||||
maybe_public_bucket_extension: Some(&extension),
|
||||
pool: &server_state.mysql_pool,
|
||||
})
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!("New file creation DB error: {:?}", err);
|
||||
MediaFileUploadError::ServerError
|
||||
})?;
|
||||
|
||||
info!("new media file id: {} token: {:?}", record_id, &token);
|
||||
|
||||
Ok(Json(OmniUploadAudioMediaFileSuccessResponse {
|
||||
success: true,
|
||||
media_file_token: token,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Map an API-key authentication failure onto this endpoint's error type. A genuine 401 stays a
|
||||
/// 401; anything else (e.g. a DB error during lookup) becomes a 500.
|
||||
fn map_api_key_auth_error(err: CommonWebError) -> MediaFileUploadError {
|
||||
match err {
|
||||
CommonWebError::NotAuthorized => {
|
||||
MediaFileUploadError::NotAuthorizedVerbose("invalid or missing API key".to_string())
|
||||
}
|
||||
_ => MediaFileUploadError::ServerError,
|
||||
}
|
||||
}
|
||||
+338
@@ -0,0 +1,338 @@
|
||||
use std::collections::HashSet;
|
||||
use std::io::Read;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_multipart::form::tempfile::TempFile;
|
||||
use actix_multipart::form::text::Text;
|
||||
use actix_multipart::form::MultipartForm;
|
||||
use actix_web::web::Json;
|
||||
use actix_web::{web, HttpRequest};
|
||||
use log::{error, info, warn};
|
||||
use once_cell::sync::Lazy;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use artcraft_api_defs::omni_api::omni_upload_image::OmniUploadImageMediaFileSuccessResponse;
|
||||
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
|
||||
use enums::by_table::media_files::media_file_class::MediaFileClass;
|
||||
use enums::by_table::media_files::media_file_type::MediaFileType;
|
||||
use enums::common::visibility::Visibility;
|
||||
use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes;
|
||||
use http_server_common::request::get_request_ip::get_request_ip;
|
||||
use mimetypes::mimetype_for_bytes::get_mimetype_for_bytes;
|
||||
use mimetypes::mimetype_to_extension::mimetype_to_extension;
|
||||
use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_idempotency_token;
|
||||
use mysql_queries::queries::media_files::create::specialized_insert::insert_media_file_from_file_upload::{insert_media_file_from_file_upload, InsertMediaFileFromUploadArgs, UploadType};
|
||||
use tokens::tokens::batch_generations::BatchGenerationToken;
|
||||
use tokens::tokens::prompts::PromptToken;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::endpoints::media_files::upload::upload_error::MediaFileUploadError;
|
||||
use crate::http_server::endpoints::media_files::upload::common_utils::try_parse_generation_provider::try_parse_generation_provider;
|
||||
use crate::http_server::user_lookup::api_keys::require_api_key_user::require_api_key_user;
|
||||
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
/// Form-multipart request fields.
|
||||
///
|
||||
/// IF VIEWING DOCS, PLEASE SEE BOTTOM OF PAGE `OmniUploadImageMediaFileForm` (Under "Schema") FOR DETAILS ON FIELDS AND NULLABILITY.
|
||||
#[derive(MultipartForm, ToSchema)]
|
||||
#[multipart(duplicate_field = "deny")]
|
||||
pub struct OmniUploadImageMediaFileForm {
|
||||
/// UUID for request idempotency
|
||||
#[multipart(limit = "2 KiB")]
|
||||
#[schema(value_type = String, format = Binary)]
|
||||
uuid_idempotency_token: Text<String>,
|
||||
|
||||
/// The uploaded file
|
||||
#[multipart(limit = "512 MiB")]
|
||||
#[schema(value_type = Vec<u8>, format = Binary)]
|
||||
file: TempFile,
|
||||
|
||||
/// Optional: Title (name) of the scene
|
||||
#[multipart(limit = "2 KiB")]
|
||||
#[schema(value_type = Option<String>, format = Binary)]
|
||||
maybe_title: Option<Text<String>>,
|
||||
|
||||
/// Optional: Visibility of the scene
|
||||
#[multipart(limit = "2 KiB")]
|
||||
#[schema(value_type = Option<Visibility>, format = Binary)]
|
||||
maybe_visibility: Option<Text<Visibility>>,
|
||||
|
||||
/// Optional: Prompt associated with this image
|
||||
/// NOTE: Cannot set `is_intermediate_system_file = true` if this is set.
|
||||
#[multipart(limit = "2 KiB")]
|
||||
#[schema(value_type = Option<String>, format = Binary)]
|
||||
maybe_prompt_token: Option<Text<PromptToken>>,
|
||||
|
||||
/// Optional: Batch associated with this image
|
||||
/// NOTE: Cannot set `is_intermediate_system_file = true` if this is set.
|
||||
#[multipart(limit = "2 KiB")]
|
||||
#[schema(value_type = Option<String>, format = Binary)]
|
||||
maybe_batch_token: Option<Text<BatchGenerationToken>>,
|
||||
|
||||
/// Optional: Whether this is a system file (e.g. cover files we should hide)
|
||||
#[multipart(limit = "2 KiB")]
|
||||
#[schema(value_type = Option<bool>, format = Binary)]
|
||||
is_intermediate_system_file: Option<Text<bool>>,
|
||||
|
||||
/// Optional: The third-party generation provider (e.g. "fal", "replicate").
|
||||
/// If set, `is_user_upload` will be false and `is_intermediate_system_file` will be forced false.
|
||||
#[multipart(limit = "2 KiB")]
|
||||
#[schema(value_type = Option<String>, format = Binary)]
|
||||
maybe_generation_provider: Option<Text<String>>,
|
||||
}
|
||||
|
||||
static ALLOWED_MIME_TYPES : Lazy<HashSet<&'static str>> = Lazy::new(|| {
|
||||
HashSet::from([
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
])
|
||||
});
|
||||
|
||||
/// API-key authenticated image upload (Omni API). Identity is read from the `Authorization` header
|
||||
/// API key rather than a session cookie.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
tag = "Omni API",
|
||||
path = "/v1/omni_api/upload/image",
|
||||
responses(
|
||||
(status = 200, description = "Success Update", body = OmniUploadImageMediaFileSuccessResponse),
|
||||
(status = 400, description = "Bad input", body = MediaFileUploadError),
|
||||
(status = 401, description = "Not authorized", body = MediaFileUploadError),
|
||||
(status = 429, description = "Too many requests", body = MediaFileUploadError),
|
||||
(status = 500, description = "Server error", body = MediaFileUploadError),
|
||||
),
|
||||
params(
|
||||
(
|
||||
"request" = OmniUploadImageMediaFileForm,
|
||||
description = "IF VIEWING DOCS, PLEASE SEE BOTTOM OF PAGE `OmniUploadImageMediaFileForm` (Under 'Schema') FOR DETAILS ON FIELDS AND NULLABILITY."
|
||||
),
|
||||
)
|
||||
)]
|
||||
pub async fn omni_upload_image_media_file_handler(
|
||||
http_request: HttpRequest,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
MultipartForm(mut form): MultipartForm<OmniUploadImageMediaFileForm>,
|
||||
) -> Result<Json<OmniUploadImageMediaFileSuccessResponse>, MediaFileUploadError> {
|
||||
|
||||
validate_form(&form)?;
|
||||
|
||||
let mut mysql_connection = server_state.mysql_pool
|
||||
.acquire()
|
||||
.await
|
||||
.map_err(|err| {
|
||||
error!("MySql pool error: {:?}", err);
|
||||
MediaFileUploadError::ServerError
|
||||
})?;
|
||||
|
||||
// ==================== 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`.
|
||||
let api_session = require_api_key_user(&http_request, &mut *mysql_connection)
|
||||
.await
|
||||
.map_err(map_api_key_auth_error)?;
|
||||
|
||||
let maybe_user_token = Some(&api_session.user_token);
|
||||
|
||||
// ==================== HANDLE IDEMPOTENCY ==================== //
|
||||
|
||||
let uuid_idempotency_token = form.uuid_idempotency_token.as_ref();
|
||||
|
||||
if let Err(reason) = validate_idempotency_token_format(uuid_idempotency_token) {
|
||||
return Err(MediaFileUploadError::BadInput(reason));
|
||||
}
|
||||
|
||||
insert_idempotency_token(uuid_idempotency_token, &mut *mysql_connection)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
error!("Error inserting idempotency token: {:?}", err);
|
||||
MediaFileUploadError::BadInput("invalid idempotency token".to_string())
|
||||
})?;
|
||||
|
||||
// ==================== UPLOAD METADATA ==================== //
|
||||
|
||||
let maybe_title = form.maybe_title.map(|title| title.to_string());
|
||||
|
||||
let creator_set_visibility = form.maybe_visibility
|
||||
.map(|visibility| visibility.0)
|
||||
.unwrap_or(Visibility::default());
|
||||
|
||||
// ==================== USER DATA ==================== //
|
||||
|
||||
let ip_address = get_request_ip(&http_request);
|
||||
|
||||
// ==================== FILE VALIDATION ==================== //
|
||||
|
||||
let mut file_bytes = Vec::new();
|
||||
form.file.file.read_to_end(&mut file_bytes)
|
||||
.map_err(|e| {
|
||||
error!("Problem reading file: {:?}", e);
|
||||
MediaFileUploadError::ServerError
|
||||
})?;
|
||||
|
||||
let mimetype = get_mimetype_for_bytes(file_bytes.as_ref())
|
||||
.map(|mimetype| mimetype.to_string())
|
||||
.ok_or_else(|| {
|
||||
warn!("Could not determine mimetype for file");
|
||||
MediaFileUploadError::BadInput("Could not determine mimetype for file".to_string())
|
||||
})?;
|
||||
|
||||
if !ALLOWED_MIME_TYPES.contains(mimetype.as_str()) {
|
||||
// NB: Don't let our error message inject malicious strings
|
||||
let filtered_mimetype = mimetype
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii())
|
||||
.filter(|c| c.is_alphanumeric() || *c == '/')
|
||||
.collect::<String>();
|
||||
return Err(MediaFileUploadError::BadInput(format!("unpermitted mime type: {}", &filtered_mimetype)));
|
||||
}
|
||||
|
||||
// ==================== OTHER FILE METADATA ==================== //
|
||||
|
||||
let maybe_filename = form.file.file_name.as_deref()
|
||||
.as_deref()
|
||||
.map(|filename| PathBuf::from(filename));
|
||||
|
||||
let extension = mimetype_to_extension(&mimetype)
|
||||
.or_else(|| {
|
||||
maybe_filename
|
||||
.as_ref()
|
||||
.and_then(|filename| filename.extension())
|
||||
.and_then(|ext| ext.to_str())
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
warn!("Could not determine file extension for mimetype: {}", &mimetype);
|
||||
MediaFileUploadError::ServerError
|
||||
})?;
|
||||
|
||||
let extension = format!(".{extension}"); // NB: needs dot prefix
|
||||
|
||||
let file_size_bytes = file_bytes.len();
|
||||
|
||||
let hash = sha256_hash_bytes(&file_bytes)
|
||||
.map_err(|io_error| {
|
||||
error!("Problem hashing bytes: {:?}", io_error);
|
||||
MediaFileUploadError::ServerError
|
||||
})?;
|
||||
|
||||
// ==================== UPLOAD AND SAVE ==================== //
|
||||
|
||||
const PREFIX : Option<&str> = Some("image_");
|
||||
|
||||
let public_upload_path = MediaFileBucketPath::generate_new(PREFIX, Some(&extension));
|
||||
|
||||
info!("Uploading media to bucket path: {}", public_upload_path.get_full_object_path_str());
|
||||
|
||||
server_state.public_bucket_client.upload_file_with_content_type(
|
||||
public_upload_path.get_full_object_path_str(),
|
||||
file_bytes.as_ref(),
|
||||
&mimetype)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!("Upload media bytes to bucket error: {:?}", e);
|
||||
MediaFileUploadError::ServerError
|
||||
})?;
|
||||
|
||||
let is_intermediate_system_file = form.is_intermediate_system_file
|
||||
.map(|b| b.0)
|
||||
.unwrap_or(false);
|
||||
|
||||
let maybe_prompt_token = form.maybe_prompt_token
|
||||
.as_ref()
|
||||
.map(|token| token.0.clone());
|
||||
|
||||
let maybe_batch_token = form.maybe_batch_token
|
||||
.as_ref()
|
||||
.map(|token| token.0.clone());
|
||||
|
||||
let maybe_generation_provider = form.maybe_generation_provider
|
||||
.as_ref()
|
||||
.and_then(|text| try_parse_generation_provider(text.as_ref()));
|
||||
|
||||
let upload_type = if maybe_generation_provider.is_some() {
|
||||
UploadType::ThirdPartyInference
|
||||
} else {
|
||||
UploadType::Filesystem
|
||||
};
|
||||
|
||||
let media_file_type = match mimetype.as_str() {
|
||||
"image/jpeg" => MediaFileType::Jpg,
|
||||
"image/png" => MediaFileType::Png,
|
||||
"image/gif" => MediaFileType::Gif,
|
||||
"image/webp" => MediaFileType::Image, // Fallback
|
||||
_ => MediaFileType::Image, // fallback
|
||||
};
|
||||
|
||||
let (token, record_id) = insert_media_file_from_file_upload(InsertMediaFileFromUploadArgs {
|
||||
maybe_media_class: Some(MediaFileClass::Image),
|
||||
media_file_type,
|
||||
maybe_creator_user_token: maybe_user_token,
|
||||
// NB: AVT (anonymous visitor) tokens are a web-session concept; API-key callers have none.
|
||||
maybe_creator_anonymous_visitor_token: None,
|
||||
creator_ip_address: &ip_address,
|
||||
creator_set_visibility,
|
||||
upload_type,
|
||||
maybe_engine_category: None,
|
||||
maybe_animation_type: None,
|
||||
maybe_mime_type: Some(&mimetype),
|
||||
maybe_prompt_token: maybe_prompt_token.as_ref(),
|
||||
maybe_batch_token: maybe_batch_token.as_ref(),
|
||||
file_size_bytes: file_size_bytes as u64,
|
||||
maybe_duration_millis: None,
|
||||
sha256_checksum: &hash,
|
||||
maybe_title: maybe_title.as_deref(),
|
||||
maybe_scene_source_media_file_token: None,
|
||||
is_intermediate_system_file,
|
||||
maybe_generation_provider,
|
||||
public_bucket_directory_hash: public_upload_path.get_object_hash(),
|
||||
maybe_public_bucket_prefix: PREFIX,
|
||||
maybe_public_bucket_extension: Some(&extension),
|
||||
pool: &server_state.mysql_pool,
|
||||
})
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!("New file creation DB error: {:?}", err);
|
||||
MediaFileUploadError::ServerError
|
||||
})?;
|
||||
|
||||
info!("new media file id: {} token: {:?}", record_id, &token);
|
||||
|
||||
Ok(Json(OmniUploadImageMediaFileSuccessResponse {
|
||||
success: true,
|
||||
media_file_token: token,
|
||||
}))
|
||||
}
|
||||
|
||||
fn validate_form(form: &OmniUploadImageMediaFileForm) -> Result<(), MediaFileUploadError> {
|
||||
if form.uuid_idempotency_token.is_empty() {
|
||||
return Err(MediaFileUploadError::BadInput("Idempotency token is required".to_string()));
|
||||
}
|
||||
|
||||
let is_intermediate_system_file = form.is_intermediate_system_file
|
||||
.as_ref()
|
||||
.map(|b| b.0)
|
||||
.unwrap_or(false);
|
||||
|
||||
if is_intermediate_system_file && form.maybe_prompt_token.is_some() {
|
||||
return Err(MediaFileUploadError::BadInput(
|
||||
"Cannot set `is_intermediate_system_file` to true if `maybe_prompt_token` is provided."
|
||||
.to_string()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Map an API-key authentication failure onto this endpoint's error type. A genuine 401 stays a
|
||||
/// 401; anything else (e.g. a DB error during lookup) becomes a 500.
|
||||
fn map_api_key_auth_error(err: CommonWebError) -> MediaFileUploadError {
|
||||
match err {
|
||||
CommonWebError::NotAuthorized => {
|
||||
MediaFileUploadError::NotAuthorizedVerbose("invalid or missing API key".to_string())
|
||||
}
|
||||
_ => MediaFileUploadError::ServerError,
|
||||
}
|
||||
}
|
||||
+557
@@ -0,0 +1,557 @@
|
||||
use std::collections::HashSet;
|
||||
use std::io::Read;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use actix_multipart::form::tempfile::TempFile;
|
||||
use actix_multipart::form::text::Text;
|
||||
use actix_multipart::form::MultipartForm;
|
||||
use actix_web::web::Json;
|
||||
use actix_web::{web, HttpRequest};
|
||||
use log::{debug, error, info, warn};
|
||||
use once_cell::sync::Lazy;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use artcraft_api_defs::omni_api::omni_upload_video::OmniUploadVideoMediaFileSuccessResponse;
|
||||
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
|
||||
use enums::by_table::media_files::media_file_class::MediaFileClass;
|
||||
use enums::by_table::media_files::media_file_type::MediaFileType;
|
||||
use enums::common::visibility::Visibility;
|
||||
use ffmpeg_utils::ffmpeg::ffmpeg_transcode_to_mp4::{ffmpeg_transcode_to_mp4, FfmpegTranscodeToMp4Args};
|
||||
use ffmpeg_utils::ffmpeg::ffmpeg_trim_and_resample::{ffmpeg_trim_and_resample, Args};
|
||||
use ffmpeg_utils::ffprobe::ffprobe_get_info::ffprobe_get_info;
|
||||
use filesys::file_read_bytes::file_read_bytes;
|
||||
use filesys::path_to_string::path_to_string;
|
||||
use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes;
|
||||
use http_server_common::request::get_request_ip::get_request_ip;
|
||||
use mimetypes::mimetype_for_bytes::get_mimetype_for_bytes;
|
||||
use mimetypes::mimetype_to_extension::mimetype_to_extension;
|
||||
use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_idempotency_token;
|
||||
use mysql_queries::queries::media_files::create::specialized_insert::insert_media_file_from_file_upload::{insert_media_file_from_file_upload, InsertMediaFileFromUploadArgs, UploadType};
|
||||
use thumbnail_generator::task_client::thumbnail_task::{ThumbnailTaskBuilder, ThumbnailTaskInputMimeType};
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use tokens::tokens::prompts::PromptToken;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::endpoints::media_files::upload::upload_error::MediaFileUploadError;
|
||||
use crate::http_server::endpoints::media_files::upload::common_utils::try_parse_generation_provider::try_parse_generation_provider;
|
||||
use crate::http_server::user_lookup::api_keys::require_api_key_user::require_api_key_user;
|
||||
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
/// Form-multipart request fields.
|
||||
///
|
||||
/// IF VIEWING DOCS, PLEASE SEE BOTTOM OF PAGE `OmniUploadVideoMediaFileForm` (Under "Schema") FOR DETAILS ON FIELDS AND NULLABILITY.
|
||||
#[derive(MultipartForm, ToSchema)]
|
||||
#[multipart(duplicate_field = "deny")]
|
||||
pub struct OmniUploadVideoMediaFileForm {
|
||||
/// UUID for request idempotency
|
||||
#[multipart(limit = "2 KiB")]
|
||||
#[schema(value_type = String, format = Binary)]
|
||||
uuid_idempotency_token: Text<String>,
|
||||
|
||||
/// The uploaded file
|
||||
#[multipart(limit = "512 MiB")]
|
||||
#[schema(value_type = Vec<u8>, format = Binary)]
|
||||
file: TempFile,
|
||||
|
||||
/// Optional: Title (name) of the scene
|
||||
#[multipart(limit = "2 KiB")]
|
||||
#[schema(value_type = Option<String>, format = Binary)]
|
||||
maybe_title: Option<Text<String>>,
|
||||
|
||||
/// Optional: Visibility of the scene
|
||||
#[multipart(limit = "2 KiB")]
|
||||
#[schema(value_type = Option<Visibility>, format = Binary)]
|
||||
maybe_visibility: Option<Text<Visibility>>,
|
||||
|
||||
/// Optional: Prompt associated with this image
|
||||
/// NOTE: Cannot set `is_intermediate_system_file = true` if this is set.
|
||||
#[multipart(limit = "2 KiB")]
|
||||
#[schema(value_type = Option<String>, format = Binary)]
|
||||
maybe_prompt_token: Option<Text<PromptToken>>,
|
||||
|
||||
/// Optional: If an engine scene was used to generate this video, provide it here to create a link.
|
||||
#[multipart(limit = "2 KiB")]
|
||||
#[schema(value_type = Option<MediaFileToken>, format = Binary)]
|
||||
maybe_scene_source_media_file_token: Option<Text<MediaFileToken>>,
|
||||
|
||||
/// Optional: Whether this is a system file (eg. cover files we should hide)
|
||||
#[multipart(limit = "2 KiB")]
|
||||
#[schema(value_type = Option<bool>, format = Binary)]
|
||||
is_intermediate_system_file: Option<Text<bool>>,
|
||||
|
||||
/// Optional: Trim start offset in milliseconds.
|
||||
#[multipart(limit = "2 KiB")]
|
||||
#[schema(value_type = Option<u64>, format = Binary)]
|
||||
maybe_trim_start_millis: Option<Text<u64>>,
|
||||
|
||||
/// Optional: Trim end offset in milliseconds.
|
||||
#[multipart(limit = "2 KiB")]
|
||||
#[schema(value_type = Option<u64>, format = Binary)]
|
||||
maybe_trim_end_millis: Option<Text<u64>>,
|
||||
|
||||
/// Optional: Resample the video to this FPS.
|
||||
/// Only certain values equal or under 24fps are allowed.
|
||||
#[multipart(limit = "2 KiB")]
|
||||
#[schema(value_type = Option<u64>, format = Binary)]
|
||||
maybe_resample_fps: Option<Text<u8>>,
|
||||
|
||||
/// Optional: The third-party generation provider (e.g. "fal", "replicate").
|
||||
/// If set, `is_user_upload` will be false and `is_intermediate_system_file` will be forced false.
|
||||
#[multipart(limit = "2 KiB")]
|
||||
#[schema(value_type = Option<String>, format = Binary)]
|
||||
maybe_generation_provider: Option<Text<String>>,
|
||||
}
|
||||
|
||||
static ALLOWED_MIME_TYPES : Lazy<HashSet<&'static str>> = Lazy::new(|| {
|
||||
HashSet::from([
|
||||
// Video
|
||||
"video/mp4", // NB: Only mp4 for now.
|
||||
])
|
||||
});
|
||||
|
||||
static TRANSCODE_MIME_TYPES : Lazy<HashSet<&'static str>> = Lazy::new(|| {
|
||||
HashSet::from([
|
||||
// Video
|
||||
"video/quicktime", // .qt quicktime files
|
||||
])
|
||||
});
|
||||
|
||||
/// API-key authenticated video upload (Omni API). Identity is read from the `Authorization` header
|
||||
/// API key rather than a session cookie.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
tag = "Omni API",
|
||||
path = "/v1/omni_api/upload/video",
|
||||
responses(
|
||||
(status = 200, description = "Success Update", body = OmniUploadVideoMediaFileSuccessResponse),
|
||||
(status = 400, description = "Bad input", body = MediaFileUploadError),
|
||||
(status = 401, description = "Not authorized", body = MediaFileUploadError),
|
||||
(status = 429, description = "Too many requests", body = MediaFileUploadError),
|
||||
(status = 500, description = "Server error", body = MediaFileUploadError),
|
||||
),
|
||||
params(
|
||||
(
|
||||
"request" = OmniUploadVideoMediaFileForm,
|
||||
description = "IF VIEWING DOCS, PLEASE SEE BOTTOM OF PAGE `OmniUploadVideoMediaFileForm` (Under 'Schema') FOR DETAILS ON FIELDS AND NULLABILITY."
|
||||
),
|
||||
)
|
||||
)]
|
||||
pub async fn omni_upload_video_media_file_handler(
|
||||
http_request: HttpRequest,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
MultipartForm(mut form): MultipartForm<OmniUploadVideoMediaFileForm>,
|
||||
) -> Result<Json<OmniUploadVideoMediaFileSuccessResponse>, MediaFileUploadError> {
|
||||
|
||||
fast_form_validations(&form)?;
|
||||
|
||||
let mut mysql_connection = server_state.mysql_pool
|
||||
.acquire()
|
||||
.await
|
||||
.map_err(|err| {
|
||||
error!("MySql pool error: {:?}", err);
|
||||
MediaFileUploadError::ServerError
|
||||
})?;
|
||||
|
||||
// ==================== 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`.
|
||||
let api_session = require_api_key_user(&http_request, &mut *mysql_connection)
|
||||
.await
|
||||
.map_err(map_api_key_auth_error)?;
|
||||
|
||||
let maybe_user_token = Some(&api_session.user_token);
|
||||
|
||||
// ==================== RATE LIMIT ==================== //
|
||||
|
||||
let rate_limiter = &server_state.redis_rate_limiters.file_upload_logged_in;
|
||||
|
||||
if let Err(_err) = rate_limiter.rate_limit_request(&http_request).await {
|
||||
return Err(MediaFileUploadError::RateLimited);
|
||||
}
|
||||
|
||||
// ==================== HANDLE IDEMPOTENCY ==================== //
|
||||
|
||||
let uuid_idempotency_token = form.uuid_idempotency_token.as_ref();
|
||||
|
||||
if let Err(reason) = validate_idempotency_token_format(uuid_idempotency_token) {
|
||||
return Err(MediaFileUploadError::BadInput(reason));
|
||||
}
|
||||
|
||||
insert_idempotency_token(uuid_idempotency_token, &mut *mysql_connection)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
error!("Error inserting idempotency token: {:?}", err);
|
||||
MediaFileUploadError::BadInput("invalid idempotency token".to_string())
|
||||
})?;
|
||||
|
||||
// ==================== UPLOAD METADATA ==================== //
|
||||
|
||||
let maybe_title = form.maybe_title
|
||||
.map(|title| title.trim().to_string())
|
||||
.filter(|title| !title.is_empty());
|
||||
|
||||
let creator_set_visibility = form.maybe_visibility
|
||||
.map(|visibility| visibility.0)
|
||||
.unwrap_or(Visibility::default());
|
||||
|
||||
// ==================== USER DATA ==================== //
|
||||
|
||||
let ip_address = get_request_ip(&http_request);
|
||||
|
||||
// ==================== FILE VALIDATION ==================== //
|
||||
|
||||
let mut file_bytes = Vec::new();
|
||||
form.file.file.read_to_end(&mut file_bytes)
|
||||
.map_err(|e| {
|
||||
error!("Problem reading file: {:?}", e);
|
||||
MediaFileUploadError::ServerError
|
||||
})?;
|
||||
|
||||
let mut mimetype = get_mimetype_for_bytes(file_bytes.as_ref())
|
||||
.map(|mimetype| mimetype.to_string())
|
||||
.ok_or_else(|| {
|
||||
warn!("Could not determine mimetype for file");
|
||||
MediaFileUploadError::BadInput("Could not determine mimetype for file".to_string())
|
||||
})?;
|
||||
|
||||
let needs_transcode = TRANSCODE_MIME_TYPES.contains(mimetype.as_str());
|
||||
|
||||
if !ALLOWED_MIME_TYPES.contains(mimetype.as_str()) && !needs_transcode {
|
||||
// NB: Don't let our error message inject malicious strings
|
||||
let filtered_mimetype = mimetype
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii())
|
||||
.filter(|c| c.is_alphanumeric() || *c == '/')
|
||||
.collect::<String>();
|
||||
return Err(MediaFileUploadError::BadInput(format!("unpermitted mime type: {}", &filtered_mimetype)));
|
||||
}
|
||||
|
||||
// ==================== ORIGINAL FILE BOOKKEEPING ==================== //
|
||||
// Hold on to the user's original file (path + mimetype + extension) so we
|
||||
// can upload it as a `_original.{ext}` sibling later if we end up
|
||||
// transcoding and/or resampling it.
|
||||
|
||||
let original_file_path: PathBuf = form.file.file.path().to_path_buf();
|
||||
let original_mimetype: String = mimetype.clone();
|
||||
let original_filename_extension: Option<String> = form.file.file_name.as_deref()
|
||||
.and_then(|filename| std::path::Path::new(filename).extension())
|
||||
.and_then(|ext| ext.to_str())
|
||||
.map(|ext| ext.to_string());
|
||||
let original_extension: String = mimetype_to_extension(&original_mimetype)
|
||||
.map(|ext| ext.to_string())
|
||||
.or(original_filename_extension)
|
||||
.ok_or_else(|| {
|
||||
warn!("Could not determine file extension for original mimetype: {}", &original_mimetype);
|
||||
MediaFileUploadError::ServerError
|
||||
})?;
|
||||
|
||||
// ==================== OPTIONAL TRANSCODE TO MP4 ==================== //
|
||||
// Some accepted source mime types (e.g. QuickTime .mov) are not the canonical
|
||||
// distribution format we want for the media file record. Transcode them to
|
||||
// mp4 in a tempdir so we can resample / upload mp4 instead.
|
||||
|
||||
let mut transcode_tempdir_ref = None;
|
||||
let mut resample_tempdir_ref = None;
|
||||
|
||||
let mut final_upload_file_path = form.file.file.path().to_path_buf();
|
||||
|
||||
if needs_transcode {
|
||||
let transcode_tempdir = server_state.temp_dir_creator.new_tempdir("ffmpeg_transcode")
|
||||
.map_err(|err| {
|
||||
error!("Problem creating transcode temp dir: {:?}", err);
|
||||
MediaFileUploadError::ServerError
|
||||
})?;
|
||||
|
||||
let transcoded_path = transcode_tempdir.path().join("transcoded.mp4");
|
||||
|
||||
info!("Transcoding {} → mp4", &original_mimetype);
|
||||
ffmpeg_transcode_to_mp4(FfmpegTranscodeToMp4Args {
|
||||
video_input_path: form.file.file.path(),
|
||||
video_output_path: &transcoded_path,
|
||||
}).map_err(|err| {
|
||||
error!("Problem transcoding video to mp4: {:?}", err);
|
||||
MediaFileUploadError::ServerError
|
||||
})?;
|
||||
|
||||
file_bytes = file_read_bytes(&transcoded_path)
|
||||
.map_err(|e| {
|
||||
error!("Problem reading transcoded mp4: {:?}", e);
|
||||
MediaFileUploadError::ServerError
|
||||
})?;
|
||||
mimetype = "video/mp4".to_string();
|
||||
final_upload_file_path = transcoded_path;
|
||||
transcode_tempdir_ref = Some(transcode_tempdir);
|
||||
}
|
||||
|
||||
// ==================== OPTIONAL VIDEO RESAMPLE ==================== //
|
||||
|
||||
let should_resample = form.maybe_resample_fps.is_some()
|
||||
|| form.maybe_trim_start_millis.is_some()
|
||||
|| form.maybe_trim_end_millis.is_some();
|
||||
|
||||
if should_resample {
|
||||
let frame_temp_dir = server_state.temp_dir_creator.new_tempdir("ffmpeg")
|
||||
.map_err(|err| {
|
||||
error!("Problem creating temp dir: {:?}", err);
|
||||
MediaFileUploadError::ServerError
|
||||
})?;
|
||||
|
||||
let video_output_path = frame_temp_dir.path().join("output.mp4");
|
||||
|
||||
let maybe_new_frame_rate = form.maybe_resample_fps.map(|fps| fps.0);
|
||||
let maybe_start_offset = form.maybe_trim_start_millis.map(|millis| Duration::from_millis(millis.0));
|
||||
let maybe_end_offset = form.maybe_trim_end_millis.map(|millis| Duration::from_millis(millis.0));
|
||||
|
||||
ffmpeg_trim_and_resample(Args {
|
||||
video_input_path: &final_upload_file_path,
|
||||
video_output_path: &video_output_path,
|
||||
maybe_new_frame_rate,
|
||||
maybe_start_offset,
|
||||
maybe_end_offset,
|
||||
}).map_err(|err| {
|
||||
error!("Problem resampling video: {:?}", err);
|
||||
MediaFileUploadError::ServerError
|
||||
})?;
|
||||
|
||||
file_bytes = file_read_bytes(&video_output_path)
|
||||
.map_err(|e| {
|
||||
error!("Problem reading file: {:?}", e);
|
||||
MediaFileUploadError::ServerError
|
||||
})?;
|
||||
|
||||
mimetype = get_mimetype_for_bytes(file_bytes.as_ref())
|
||||
.map(|mimetype| mimetype.to_string())
|
||||
.ok_or_else(|| {
|
||||
warn!("Could not determine mimetype for file");
|
||||
MediaFileUploadError::BadInput("Could not determine mimetype for file".to_string())
|
||||
})?;
|
||||
|
||||
final_upload_file_path = video_output_path;
|
||||
resample_tempdir_ref = Some(frame_temp_dir); // NB: Keep from going out of scope
|
||||
}
|
||||
|
||||
// True if we touched the user's file in any way (transcoded, resampled, or both).
|
||||
let should_upload_original = needs_transcode || should_resample;
|
||||
|
||||
// ==================== OTHER FILE METADATA ==================== //
|
||||
|
||||
let mut maybe_duration_millis = None;
|
||||
|
||||
match ffprobe_get_info(&final_upload_file_path) {
|
||||
Ok(video_info) => {
|
||||
maybe_duration_millis = video_info.duration
|
||||
.map(|duration| duration.millis as u64);
|
||||
}
|
||||
Err(error) => {
|
||||
warn!("Error reading video dimensions with ffprobe: {:?}", error);
|
||||
}
|
||||
}
|
||||
|
||||
let maybe_filename = form.file.file_name.as_deref()
|
||||
.as_deref()
|
||||
.map(|filename| PathBuf::from(filename));
|
||||
|
||||
let extension = mimetype_to_extension(&mimetype)
|
||||
.or_else(|| {
|
||||
maybe_filename
|
||||
.as_ref()
|
||||
.and_then(|filename| filename.extension())
|
||||
.and_then(|ext| ext.to_str())
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
warn!("Could not determine file extension for mimetype: {}", &mimetype);
|
||||
MediaFileUploadError::ServerError
|
||||
})?;
|
||||
|
||||
let extension = format!(".{extension}"); // NB: needs dot prefix
|
||||
|
||||
let file_size_bytes = file_bytes.len();
|
||||
|
||||
let hash = sha256_hash_bytes(&file_bytes)
|
||||
.map_err(|io_error| {
|
||||
error!("Problem hashing bytes: {:?}", io_error);
|
||||
MediaFileUploadError::ServerError
|
||||
})?;
|
||||
|
||||
// ==================== UPLOAD AND SAVE ==================== //
|
||||
|
||||
const PREFIX : Option<&str> = Some("video_");
|
||||
|
||||
let public_upload_path = MediaFileBucketPath::generate_new(PREFIX, Some(&extension));
|
||||
|
||||
info!("Uploading media to bucket path: {}", public_upload_path.get_full_object_path_str());
|
||||
|
||||
server_state.public_bucket_client.upload_file_with_content_type(
|
||||
public_upload_path.get_full_object_path_str(),
|
||||
file_bytes.as_ref(),
|
||||
&mimetype)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!("Upload media bytes to bucket error: {:?}", e);
|
||||
MediaFileUploadError::ServerError
|
||||
})?;
|
||||
|
||||
// ==================== UPLOAD ORIGINAL SIBLING ==================== //
|
||||
// If we transcoded and/or resampled the user's upload, also upload the
|
||||
// unmodified original alongside the canonical file as `_original.{ext}`.
|
||||
|
||||
if should_upload_original {
|
||||
let original_suffix = format!("_original.{}", original_extension);
|
||||
let original_bucket_path = MediaFileBucketPath::from_object_hash(
|
||||
public_upload_path.get_object_hash(),
|
||||
PREFIX,
|
||||
Some(&original_suffix),
|
||||
);
|
||||
|
||||
info!(
|
||||
"Uploading original sibling to bucket path: {}",
|
||||
original_bucket_path.get_full_object_path_str(),
|
||||
);
|
||||
|
||||
let original_bytes = file_read_bytes(&original_file_path)
|
||||
.map_err(|e| {
|
||||
error!("Problem reading original file for sibling upload: {:?}", e);
|
||||
MediaFileUploadError::ServerError
|
||||
})?;
|
||||
|
||||
server_state.public_bucket_client.upload_file_with_content_type(
|
||||
original_bucket_path.get_full_object_path_str(),
|
||||
original_bytes.as_ref(),
|
||||
&original_mimetype,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
// NB: Sibling failure is non-fatal — the canonical upload already
|
||||
// succeeded and the user can still use the media. Log loudly.
|
||||
warn!("Upload original sibling to bucket error: {:?}", e);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
let maybe_scene_source_media_file_token = form.maybe_scene_source_media_file_token
|
||||
.as_ref()
|
||||
.map(|token| &token.0);
|
||||
|
||||
let maybe_prompt_token = form.maybe_prompt_token
|
||||
.as_ref()
|
||||
.map(|token| token.0.clone());
|
||||
|
||||
// NB: If we're uploading a video file that references an engine scene, then this is an engine
|
||||
// render video, and we should mark it as a system (hidden) file.
|
||||
let is_intermediate_system_file =
|
||||
maybe_scene_source_media_file_token.is_some() ||
|
||||
form.is_intermediate_system_file.map(|b| b.0).unwrap_or(false);
|
||||
|
||||
let maybe_generation_provider = form.maybe_generation_provider
|
||||
.as_ref()
|
||||
.and_then(|text| try_parse_generation_provider(text.as_ref()));
|
||||
|
||||
let upload_type = if maybe_generation_provider.is_some() {
|
||||
UploadType::ThirdPartyInference
|
||||
} else {
|
||||
UploadType::Filesystem
|
||||
};
|
||||
|
||||
let (token, record_id) = insert_media_file_from_file_upload(InsertMediaFileFromUploadArgs {
|
||||
maybe_media_class: Some(MediaFileClass::Video),
|
||||
media_file_type: MediaFileType::Video,
|
||||
maybe_creator_user_token: maybe_user_token,
|
||||
// NB: AVT (anonymous visitor) tokens are a web-session concept; API-key callers have none.
|
||||
maybe_creator_anonymous_visitor_token: None,
|
||||
creator_ip_address: &ip_address,
|
||||
creator_set_visibility,
|
||||
upload_type,
|
||||
maybe_engine_category: None,
|
||||
maybe_animation_type: None,
|
||||
maybe_prompt_token: maybe_prompt_token.as_ref(),
|
||||
maybe_batch_token: None,
|
||||
maybe_mime_type: Some(&mimetype),
|
||||
file_size_bytes: file_size_bytes as u64,
|
||||
maybe_duration_millis,
|
||||
sha256_checksum: &hash,
|
||||
maybe_title: maybe_title.as_deref(),
|
||||
maybe_scene_source_media_file_token,
|
||||
is_intermediate_system_file, // NB: is_user_upload = TRUE
|
||||
maybe_generation_provider,
|
||||
public_bucket_directory_hash: public_upload_path.get_object_hash(),
|
||||
maybe_public_bucket_prefix: PREFIX,
|
||||
maybe_public_bucket_extension: Some(&extension),
|
||||
pool: &server_state.mysql_pool,
|
||||
})
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!("New file creation DB error: {:?}", err);
|
||||
MediaFileUploadError::ServerError
|
||||
})?;
|
||||
|
||||
info!("new media file id: {} token: {:?}", record_id, &token);
|
||||
|
||||
let thumbnail_task_result = ThumbnailTaskBuilder::new_for_source_mimetype(ThumbnailTaskInputMimeType::MP4)
|
||||
.with_bucket(server_state.public_bucket_client.bucket_name().as_str())
|
||||
.with_path(&*path_to_string(public_upload_path.to_full_object_pathbuf()))
|
||||
.with_output_suffix("thumb")
|
||||
.with_event_id(&token.to_string())
|
||||
.send_all()
|
||||
.await;
|
||||
|
||||
match thumbnail_task_result {
|
||||
Ok(thumbnail_task) => {
|
||||
debug!("Thumbnail tasks sent: {:?}", thumbnail_task);
|
||||
},
|
||||
Err(e) => {
|
||||
error!("Failed to create some/all thumbnail tasks: {:?}", e);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(OmniUploadVideoMediaFileSuccessResponse {
|
||||
success: true,
|
||||
media_file_token: token,
|
||||
}))
|
||||
}
|
||||
|
||||
fn fast_form_validations(form: &OmniUploadVideoMediaFileForm) -> Result<(), MediaFileUploadError> {
|
||||
if let Some(resample_fps) = form.maybe_resample_fps.as_ref() {
|
||||
if **resample_fps > 24 {
|
||||
return Err(MediaFileUploadError::BadInput("Resample FPS must be 24 or lower".to_string()));
|
||||
} else if **resample_fps == 0 {
|
||||
return Err(MediaFileUploadError::BadInput("Resample FPS must be greater than 0".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
form.maybe_trim_start_millis.as_ref().zip(form.maybe_trim_end_millis.as_ref())
|
||||
.map(|(start, end)| {
|
||||
if **start >= **end {
|
||||
return Err(MediaFileUploadError::BadInput("Trim start must be less than trim end".to_string()));
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.transpose()?;
|
||||
|
||||
let is_intermediate_system_file = form.is_intermediate_system_file
|
||||
.as_ref()
|
||||
.map(|b| b.0)
|
||||
.unwrap_or(false);
|
||||
|
||||
if is_intermediate_system_file && form.maybe_prompt_token.is_some() {
|
||||
return Err(MediaFileUploadError::BadInput(
|
||||
"Cannot set `is_intermediate_system_file` to true if `maybe_prompt_token` is provided."
|
||||
.to_string()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Map an API-key authentication failure onto this endpoint's error type. A genuine 401 stays a
|
||||
/// 401; anything else (e.g. a DB error during lookup) becomes a 500.
|
||||
fn map_api_key_auth_error(err: CommonWebError) -> MediaFileUploadError {
|
||||
match err {
|
||||
CommonWebError::NotAuthorized => {
|
||||
MediaFileUploadError::NotAuthorizedVerbose("invalid or missing API key".to_string())
|
||||
}
|
||||
_ => MediaFileUploadError::ServerError,
|
||||
}
|
||||
}
|
||||
+3
-8
@@ -1,7 +1,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::http::StatusCode;
|
||||
use actix_web::{web, HttpRequest, HttpResponse, ResponseError};
|
||||
use actix_web::{web, HttpRequest};
|
||||
use log::{error, info};
|
||||
|
||||
use http_server_common::request::get_request_ip::get_request_ip;
|
||||
@@ -32,7 +32,7 @@ pub struct SetUnsubscribeReasonResponse {
|
||||
pub async fn set_unsubscribe_reason_handler(
|
||||
http_request: HttpRequest,
|
||||
request: web::Json<SetUnsubscribeReasonRequest>,
|
||||
server_state: web::Data<Arc<ServerState>>) -> Result<HttpResponse, CommonWebError>
|
||||
server_state: web::Data<Arc<ServerState>>) -> Result<web::Json<SetUnsubscribeReasonResponse>, CommonWebError>
|
||||
{
|
||||
let mut mysql_connection = server_state.mysql_pool.acquire()
|
||||
.await
|
||||
@@ -73,10 +73,5 @@ pub async fn set_unsubscribe_reason_handler(
|
||||
success: true,
|
||||
};
|
||||
|
||||
let body = serde_json::to_string(&response)
|
||||
.map_err(|_e| CommonWebError::from_error(_e))?;
|
||||
|
||||
Ok(HttpResponse::Ok()
|
||||
.content_type("application/json")
|
||||
.body(body))
|
||||
Ok(web::Json(response))
|
||||
}
|
||||
|
||||
+3
-8
@@ -13,7 +13,7 @@ use std::sync::Arc;
|
||||
|
||||
use actix_web::error::ResponseError;
|
||||
use actix_web::http::StatusCode;
|
||||
use actix_web::{web, HttpRequest, HttpResponse};
|
||||
use actix_web::{web, HttpRequest};
|
||||
use log::error;
|
||||
|
||||
use enums::by_table::trending_model_analytics::window_name::WindowName;
|
||||
@@ -51,7 +51,7 @@ pub type WindowTrends = HashMap<WindowName, Vec<TtsModelToken>>;
|
||||
|
||||
pub async fn list_trending_tts_models_handler(
|
||||
_http_request: HttpRequest,
|
||||
server_state: web::Data<Arc<ServerState>>) -> Result<HttpResponse, CommonWebError>
|
||||
server_state: web::Data<Arc<ServerState>>) -> Result<web::Json<ListTrendingTtsModelsResponse>, CommonWebError>
|
||||
{
|
||||
|
||||
// TODO: Cache the outputs!
|
||||
@@ -112,10 +112,5 @@ pub async fn list_trending_tts_models_handler(
|
||||
top_trending_by_language_code,
|
||||
};
|
||||
|
||||
let body = serde_json::to_string(&response)
|
||||
.map_err(CommonWebError::from_error)?;
|
||||
|
||||
Ok(HttpResponse::Ok()
|
||||
.content_type("application/json")
|
||||
.body(body))
|
||||
Ok(web::Json(response))
|
||||
}
|
||||
|
||||
+4
-4
@@ -4,9 +4,10 @@ use std::sync::Arc;
|
||||
use actix_web::error::ResponseError;
|
||||
use actix_web::http::StatusCode;
|
||||
use actix_web::web::Path;
|
||||
use actix_web::{web, HttpRequest, HttpResponse};
|
||||
use actix_web::{web, HttpRequest};
|
||||
use log::warn;
|
||||
|
||||
use artcraft_api_defs::common::responses::simple_generic_json_success::SimpleGenericJsonSuccess;
|
||||
use http_server_common::request::get_request_ip::get_request_ip;
|
||||
use mysql_queries::queries::tts::tts_models::delete_tts_model_various_scopes::delete_tts_model_as_mod;
|
||||
use mysql_queries::queries::tts::tts_models::delete_tts_model_various_scopes::delete_tts_model_as_user;
|
||||
@@ -16,7 +17,6 @@ use mysql_queries::queries::tts::tts_models::get_tts_model::get_tts_model_by_tok
|
||||
|
||||
use crate::http_server::web_utils::response_error_helpers::to_simple_json_error;
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::web_utils::response_success_helpers::simple_json_success;
|
||||
use crate::state::server_state::ServerState;
|
||||
use crate::util::delete_role_disambiguation::delete_role_disambiguation;
|
||||
use crate::util::delete_role_disambiguation::DeleteRole;
|
||||
@@ -39,7 +39,7 @@ pub async fn delete_tts_model_handler(
|
||||
path: Path<DeleteTtsModelPathInfo>,
|
||||
request: web::Json<DeleteTtsModelRequest>,
|
||||
server_state: web::Data<Arc<ServerState>>
|
||||
) -> Result<HttpResponse, CommonWebError> {
|
||||
) -> Result<web::Json<SimpleGenericJsonSuccess>, CommonWebError> {
|
||||
// NB: Disable if we've migrated to model_weights
|
||||
if server_state.flags.switch_tts_to_model_weights {
|
||||
warn!("Migration to model_weights for tts. Cannot delete old model.");
|
||||
@@ -154,5 +154,5 @@ pub async fn delete_tts_model_handler(
|
||||
}
|
||||
};
|
||||
|
||||
Ok(simple_json_success())
|
||||
Ok(web::Json(SimpleGenericJsonSuccess { success: true }))
|
||||
}
|
||||
|
||||
+4
-4
@@ -4,9 +4,10 @@ use std::sync::Arc;
|
||||
use actix_web::error::ResponseError;
|
||||
use actix_web::http::StatusCode;
|
||||
use actix_web::web::Path;
|
||||
use actix_web::{web, HttpRequest, HttpResponse};
|
||||
use actix_web::{web, HttpRequest};
|
||||
use log::warn;
|
||||
|
||||
use artcraft_api_defs::common::responses::simple_generic_json_success::SimpleGenericJsonSuccess;
|
||||
use mysql_queries::queries::tts::tts_results::delete_tts_result_various_scopes::delete_tts_inference_result_as_mod;
|
||||
use mysql_queries::queries::tts::tts_results::delete_tts_result_various_scopes::delete_tts_inference_result_as_user;
|
||||
use mysql_queries::queries::tts::tts_results::delete_tts_result_various_scopes::undelete_tts_inference_result_as_mod;
|
||||
@@ -15,7 +16,6 @@ use mysql_queries::queries::tts::tts_results::query_tts_result::select_tts_resul
|
||||
|
||||
use crate::http_server::web_utils::response_error_helpers::to_simple_json_error;
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::web_utils::response_success_helpers::simple_json_success;
|
||||
use crate::state::server_state::ServerState;
|
||||
use crate::util::delete_role_disambiguation::delete_role_disambiguation;
|
||||
use crate::util::delete_role_disambiguation::DeleteRole;
|
||||
@@ -38,7 +38,7 @@ pub async fn delete_tts_inference_result_handler(
|
||||
path: Path<DeleteTtsInferenceResultPathInfo>,
|
||||
request: web::Json<DeleteTtsInferenceResultRequest>,
|
||||
server_state: web::Data<Arc<ServerState>>
|
||||
) -> Result<HttpResponse, CommonWebError> {
|
||||
) -> Result<web::Json<SimpleGenericJsonSuccess>, CommonWebError> {
|
||||
let maybe_user_session = server_state
|
||||
.session_checker
|
||||
.maybe_get_user_session(&http_request, &server_state.mysql_pool)
|
||||
@@ -141,5 +141,5 @@ pub async fn delete_tts_inference_result_handler(
|
||||
}
|
||||
};
|
||||
|
||||
Ok(simple_json_success())
|
||||
Ok(web::Json(SimpleGenericJsonSuccess { success: true }))
|
||||
}
|
||||
|
||||
@@ -6,15 +6,15 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::web::{Json, Path};
|
||||
use actix_web::{web, HttpRequest, HttpResponse};
|
||||
use actix_web::{web, HttpRequest};
|
||||
use language_tags::LanguageTag;
|
||||
use log::{error, info, warn};
|
||||
use sqlx::MySqlPool;
|
||||
|
||||
use crate::configs::supported_languages_for_models::get_canonicalized_language_tag_for_model;
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::web_utils::response_success_helpers::simple_json_success;
|
||||
use crate::state::server_state::ServerState;
|
||||
use artcraft_api_defs::common::responses::simple_generic_json_success::SimpleGenericJsonSuccess;
|
||||
use enums::common::visibility::Visibility;
|
||||
use http_server_common::request::get_request_ip::get_request_ip;
|
||||
use markdown::simple_markdown_to_html::simple_markdown_to_html;
|
||||
@@ -92,7 +92,7 @@ pub async fn edit_tts_model_handler(
|
||||
http_request: HttpRequest,
|
||||
path: Path<EditTtsModelPathInfo>,
|
||||
request: Json<EditTtsModelRequest>,
|
||||
server_state: web::Data<Arc<ServerState>>) -> Result<HttpResponse, CommonWebError>
|
||||
server_state: web::Data<Arc<ServerState>>) -> Result<web::Json<SimpleGenericJsonSuccess>, CommonWebError>
|
||||
{
|
||||
// NB: Disable if we've migrated to model_weights
|
||||
if server_state.flags.switch_tts_to_model_weights {
|
||||
@@ -300,7 +300,7 @@ pub async fn edit_tts_model_handler(
|
||||
let _r = redis_ttl_cache.delete_from_cache(&cache_key).ok();
|
||||
}
|
||||
|
||||
Ok(simple_json_success())
|
||||
Ok(web::Json(SimpleGenericJsonSuccess { success: true }))
|
||||
}
|
||||
|
||||
async fn update_mod_details(
|
||||
|
||||
@@ -6,16 +6,16 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::web::Path;
|
||||
use actix_web::{web, HttpRequest, HttpResponse};
|
||||
use actix_web::{web, HttpRequest};
|
||||
use log::{error, warn};
|
||||
|
||||
use artcraft_api_defs::common::responses::simple_generic_json_success::SimpleGenericJsonSuccess;
|
||||
use enums::common::visibility::Visibility;
|
||||
use http_server_common::request::get_request_ip::get_request_ip;
|
||||
use mysql_queries::queries::tts::tts_results::edit_tts_result::{edit_tts_result, CreatorOrModFields, EditTtsResultArgs};
|
||||
use mysql_queries::queries::tts::tts_results::query_tts_result::select_tts_result_by_token;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::web_utils::response_success_helpers::simple_json_success;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
/// For the URL PathInfo
|
||||
@@ -34,7 +34,7 @@ pub async fn edit_tts_inference_result_handler(
|
||||
http_request: HttpRequest,
|
||||
path: Path<EditTtsResultPathInfo>,
|
||||
request: web::Json<EditTtsResultRequest>,
|
||||
server_state: web::Data<Arc<ServerState>>) -> Result<HttpResponse, CommonWebError>
|
||||
server_state: web::Data<Arc<ServerState>>) -> Result<web::Json<SimpleGenericJsonSuccess>, CommonWebError>
|
||||
{
|
||||
let maybe_user_session = server_state
|
||||
.session_checker
|
||||
@@ -120,5 +120,5 @@ pub async fn edit_tts_inference_result_handler(
|
||||
CommonWebError::from_anyhow_error(err)
|
||||
})?;
|
||||
|
||||
Ok(simple_json_success())
|
||||
Ok(web::Json(SimpleGenericJsonSuccess { success: true }))
|
||||
}
|
||||
|
||||
+5
-12
@@ -5,7 +5,7 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::{web, HttpRequest, HttpResponse};
|
||||
use actix_web::{web, HttpRequest};
|
||||
use log::warn;
|
||||
|
||||
use crate::http_server::validations::validate_model_title::validate_model_title;
|
||||
@@ -71,8 +71,8 @@ pub async fn upload_tts_model_handler(
|
||||
http_request: HttpRequest,
|
||||
request: web::Json<UploadTtsModelRequest>,
|
||||
server_state: web::Data<Arc<ServerState>>
|
||||
) -> Result<HttpResponse, CommonWebError> {
|
||||
|
||||
) -> Result<web::Json<UploadTtsModelSuccessResponse>, CommonWebError> {
|
||||
|
||||
if server_state.flags.disable_tts {
|
||||
return Err(CommonWebError::TooManyRequests);
|
||||
}
|
||||
@@ -186,18 +186,11 @@ pub async fn upload_tts_model_handler(
|
||||
CommonWebError::from_anyhow_error(e)
|
||||
})?;
|
||||
|
||||
let response = UploadTtsModelSuccessResponse {
|
||||
Ok(web::Json(UploadTtsModelSuccessResponse {
|
||||
success: true,
|
||||
job_token: job_token.to_string(),
|
||||
job_type: download_job_type,
|
||||
};
|
||||
|
||||
let body = serde_json::to_string(&response)
|
||||
?;
|
||||
|
||||
Ok(HttpResponse::Ok()
|
||||
.content_type("application/json")
|
||||
.body(body))
|
||||
}))
|
||||
}
|
||||
|
||||
fn validate_idempotency_token(token: &str) -> Result<(), String> {
|
||||
|
||||
+4
-14
@@ -4,7 +4,7 @@ use std::sync::Arc;
|
||||
use actix_web::error::ResponseError;
|
||||
use actix_web::http::StatusCode;
|
||||
use actix_web::web::Path;
|
||||
use actix_web::{web, HttpMessage, HttpRequest, HttpResponse};
|
||||
use actix_web::{web, HttpMessage, HttpRequest};
|
||||
use chrono::{DateTime, Utc};
|
||||
use log::error;
|
||||
use redis::{Commands, RedisResult};
|
||||
@@ -61,7 +61,7 @@ pub struct GetTtsInferenceStatusSuccessResponse {
|
||||
pub async fn get_tts_inference_job_status_handler(
|
||||
http_request: HttpRequest,
|
||||
path: Path<GetTtsInferenceStatusPathInfo>,
|
||||
server_state: web::Data<Arc<ServerState>>) -> Result<HttpResponse, CommonWebError>
|
||||
server_state: web::Data<Arc<ServerState>>) -> Result<web::Json<GetTtsInferenceStatusSuccessResponse>, CommonWebError>
|
||||
{
|
||||
let job_token = path.into_inner().token;
|
||||
|
||||
@@ -80,20 +80,10 @@ pub async fn get_tts_inference_job_status_handler(
|
||||
legacy_lookup(&job_token, &server_state).await?
|
||||
};
|
||||
|
||||
let response = GetTtsInferenceStatusSuccessResponse {
|
||||
Ok(web::Json(GetTtsInferenceStatusSuccessResponse {
|
||||
success: true,
|
||||
state: record_for_response,
|
||||
};
|
||||
|
||||
let body = serde_json::to_string(&response)
|
||||
.map_err(|e| {
|
||||
error!("error returning response: {:?}", e);
|
||||
CommonWebError::from_error(e)
|
||||
})?;
|
||||
|
||||
Ok(HttpResponse::Ok()
|
||||
.content_type("application/json")
|
||||
.body(body))
|
||||
}))
|
||||
}
|
||||
|
||||
async fn legacy_lookup(job_token: &str, server_state: &ServerState)
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::web::Path;
|
||||
use actix_web::{web, HttpRequest, HttpResponse};
|
||||
use actix_web::{web, HttpRequest};
|
||||
use chrono::{DateTime, Utc};
|
||||
use log::warn;
|
||||
|
||||
@@ -152,7 +152,7 @@ pub struct TtsModelModeratorFieldInfo {
|
||||
pub async fn get_tts_model_handler(
|
||||
http_request: HttpRequest,
|
||||
path: Path<GetTtsModelPathInfo>,
|
||||
server_state: web::Data<Arc<ServerState>>) -> Result<HttpResponse, CommonWebError>
|
||||
server_state: web::Data<Arc<ServerState>>) -> Result<web::Json<GetTtsModelSuccessResponse>, CommonWebError>
|
||||
{
|
||||
let mut mysql_connection = server_state.mysql_pool.acquire()
|
||||
.await
|
||||
@@ -327,10 +327,5 @@ pub async fn get_tts_model_handler(
|
||||
},
|
||||
};
|
||||
|
||||
let body = serde_json::to_string(&response)
|
||||
.map_err(CommonWebError::from_error)?;
|
||||
|
||||
Ok(HttpResponse::Ok()
|
||||
.content_type("application/json")
|
||||
.body(body))
|
||||
Ok(web::Json(response))
|
||||
}
|
||||
|
||||
+4
-11
@@ -3,7 +3,7 @@ use std::sync::Arc;
|
||||
use actix_web::error::ResponseError;
|
||||
use actix_web::http::StatusCode;
|
||||
use actix_web::web::Path;
|
||||
use actix_web::{web, HttpMessage, HttpRequest, HttpResponse};
|
||||
use actix_web::{web, HttpMessage, HttpRequest};
|
||||
use derive_more::Display;
|
||||
use log::warn;
|
||||
use redis::Commands;
|
||||
@@ -28,7 +28,7 @@ pub struct GetTtsModelUseCountSuccessResponse {
|
||||
pub async fn get_tts_model_use_count_handler(
|
||||
http_request: HttpRequest,
|
||||
path: Path<GetTtsModelUseCountPathInfo>,
|
||||
server_state: web::Data<Arc<ServerState>>) -> Result<HttpResponse, CommonWebError>
|
||||
server_state: web::Data<Arc<ServerState>>) -> Result<web::Json<GetTtsModelUseCountSuccessResponse>, CommonWebError>
|
||||
{
|
||||
let mut redis = server_state.redis_pool
|
||||
.get()
|
||||
@@ -45,15 +45,8 @@ pub async fn get_tts_model_use_count_handler(
|
||||
CommonWebError::from_error(e)
|
||||
})?;
|
||||
|
||||
let response = GetTtsModelUseCountSuccessResponse {
|
||||
Ok(web::Json(GetTtsModelUseCountSuccessResponse {
|
||||
success: true,
|
||||
count,
|
||||
};
|
||||
|
||||
let body = serde_json::to_string(&response)
|
||||
.map_err(CommonWebError::from_error)?;
|
||||
|
||||
Ok(HttpResponse::Ok()
|
||||
.content_type("application/json")
|
||||
.body(body))
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::sync::Arc;
|
||||
use actix_web::error::ResponseError;
|
||||
use actix_web::http::StatusCode;
|
||||
use actix_web::web::Path;
|
||||
use actix_web::{web, HttpMessage, HttpRequest, HttpResponse};
|
||||
use actix_web::{web, HttpMessage, HttpRequest};
|
||||
use log::warn;
|
||||
|
||||
use mysql_queries::queries::tts::tts_results::query_tts_result::select_tts_result_by_token;
|
||||
@@ -29,7 +29,7 @@ pub struct GetTtsResultSuccessResponse {
|
||||
pub async fn get_tts_inference_result_handler(
|
||||
http_request: HttpRequest,
|
||||
path: Path<GetTtsResultPathInfo>,
|
||||
server_state: web::Data<Arc<ServerState>>) -> Result<HttpResponse, CommonWebError>
|
||||
server_state: web::Data<Arc<ServerState>>) -> Result<web::Json<GetTtsResultSuccessResponse>, CommonWebError>
|
||||
{
|
||||
let maybe_user_session = server_state
|
||||
.session_checker
|
||||
@@ -80,15 +80,8 @@ pub async fn get_tts_inference_result_handler(
|
||||
inference_result.maybe_moderator_fields = None;
|
||||
}
|
||||
|
||||
let response = GetTtsResultSuccessResponse {
|
||||
Ok(web::Json(GetTtsResultSuccessResponse {
|
||||
success: true,
|
||||
result: inference_result,
|
||||
};
|
||||
|
||||
let body = serde_json::to_string(&response)
|
||||
.map_err(|e| CommonWebError::from_error(e))?;
|
||||
|
||||
Ok(HttpResponse::Ok()
|
||||
.content_type("application/json")
|
||||
.body(body))
|
||||
}))
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user